feat(events): context injection for opencode and JSON-envelope agent hooks (#3934)

* feat(events): context injection for opencode and JSON-envelope agent hooks

Adds first-class context injection to agent runtime events:

1. opencode: maps session_start to experimental.chat.system.transform (injects into system prompt) and user_prompt_submit to chat.message (injects synthetic TextPart). TS plugin captures runEvent stdout (stdio pipe, encoding utf-8) and pushes into output objects. Part IDs derive from output.parts[last].id to preserve OpenCode's prt_ brand and prevent session schema crashes.

2. JSON-envelope hook wrapping: adds events_context_envelope to IntegrationBase so agents that require JSON on stdout receive their target envelope via the dispatcher's 5th argument:
   - gemini, tabnine, qwen, devin: hookSpecificOutput.additionalContext on session_start/user_prompt_submit; suppress on non-injectable events (prevents systemMessage user-facing noise)
   - copilot: top-level additionalContext on session_start
   - cursor: top-level additional_context on session_start; suppress elsewhere
   - claude, codex: plain stdout passthrough (already injected)

3. Dispatcher template and resolve_and_run_event_command parse the 5th envelope arg and wrap stdout accordingly.

Tests added for opencode TextPart schema, part ID derivation, envelope command generation, and dispatcher output wrapping. All 162 events/integration tests pass.

* fix(events): address code review on #3934

- Qwen/Gemini/Tabnine/Devin: include native hookEventName inside
  hookSpecificOutput envelope (required by Qwen's hooks spec). Thread
  the native event name from the integration's CANONICAL_TO_NATIVE
  through _dispatcher_command as a 6th dispatcher argument, through
  the dispatcher template's main()/_run_inline()/_emit(), and through
  resolve_and_run_event_command()/_emit_event_stdout().
- Copilot: map user_prompt_submit to additionalContext (previously
  unmapped, breaking per-prompt context injection despite Copilot CLI
  supporting it via userPromptSubmitted).
- OpenCode: guard experimental.chat.system.transform so canonical
  session_start handlers only run when input.sessionID is present —
  OpenCode fires this hook for non-session operations (e.g. agent
  generation) with no sessionID.

Assisted-by: opencode (model: glm-5.2, supervised)

* fix(events): address second Copilot review round on #3934

- Positional arg alignment: always emit default timeout (60s) as the
  4th dispatcher argument even when timeout_seconds is omitted, so the
  envelope (5th) and native_event (6th) land in the correct argv slots.
  Previously, omitting timeout_seconds caused the envelope to be parsed
  as an invalid timeout, silently falling back to plain stdout.
- OpenCode session_start caching: cache handler output per sessionID in
  the generated TS plugin so non-idempotent handlers (setup, telemetry,
  file-mutating scripts) run once per session instead of on every LLM
  request. Cache is evicted on session.deleted.
- Updated PR description to reflect Copilot user_prompt_submit now maps
  to additionalContext (was documented as plain/unprocessed).

Assisted-by: opencode (model: glm-5.2, supervised)
This commit is contained in:
kanfil
2026-08-04 07:32:30 -05:00
committed by GitHub
parent e57a86cc9c
commit 99970560db
10 changed files with 437 additions and 24 deletions
+230 -21
View File
@@ -251,7 +251,7 @@ def _resolve_argv(template_path, project_root, ext_id):
return [str(script_abs), *rest]
def _run_inline(command_name, payload, project_root, timeout):
def _run_inline(command_name, payload, project_root, timeout, envelope="plain", native_event=""):
"""Resolve and run the event command with stdlib only (no specify_cli)."""
template_path, ext_id = _find_command_template(command_name, project_root)
if not template_path:
@@ -269,7 +269,7 @@ def _run_inline(command_name, payload, project_root, timeout):
cwd=str(project_root),
)
if result.stdout:
sys.stdout.write(result.stdout)
_emit(result.stdout, envelope, native_event)
if result.returncode != 0:
if result.stderr:
sys.stderr.write(result.stderr)
@@ -283,6 +283,46 @@ def _run_inline(command_name, payload, project_root, timeout):
return 2
def _emit(output, envelope, native_event=""):
"""Write handler output to stdout in the agent's context-injection shape.
Not every agent injects a hook's plain-text stdout as model context:
Gemini/Tabnine/Qwen/Devin are JSON-only protocols (plain text becomes
user-facing noise, never context), Copilot discards non-JSON stdout, and
Cursor parses stdout as JSON. The native hook command passes the envelope
as the dispatcher's 5th argument (see events_context_envelope on the
integration classes), and the native event name as the 6th argument so
hookSpecificOutput can include hookEventName:
hookSpecificOutput → {"hookSpecificOutput": {"hookEventName": ..., "additionalContext": ...}}
additionalContext → {"additionalContext": ...} (top-level, Copilot)
additional_context → {"additional_context": ...} (top-level, Cursor)
suppress → emit nothing (strict-JSON agents on events whose
output can't be used)
plain (default) → passthrough (Claude/Codex inject plain stdout)
Empty output emits nothing under any envelope (an empty additionalContext
is useless noise).
"""
if not output:
return
if envelope == "suppress":
return
if envelope == "hookSpecificOutput":
payload = {"additionalContext": output}
if native_event:
payload["hookEventName"] = native_event
sys.stdout.write(json.dumps({"hookSpecificOutput": payload}) + "\\n")
return
if envelope == "additionalContext":
sys.stdout.write(json.dumps({"additionalContext": output}) + "\\n")
return
if envelope == "additional_context":
sys.stdout.write(json.dumps({"additional_context": output}) + "\\n")
return
sys.stdout.write(output)
def main():
if len(sys.argv) < 3:
sys.exit(0)
@@ -297,6 +337,16 @@ def main():
timeout = int(sys.argv[3])
except (TypeError, ValueError):
timeout = 120
# Optional 5th arg: context-injection envelope for stdout (C13): plain
# (default), hookSpecificOutput, additionalContext, additional_context,
# or suppress. Unknown values fall back to plain passthrough.
envelope = sys.argv[4] if len(sys.argv) >= 5 else "plain"
if envelope not in ("plain", "hookSpecificOutput", "additionalContext", "additional_context", "suppress"):
envelope = "plain"
# Optional 6th arg: native event name for hookSpecificOutput's
# hookEventName field (required by Qwen's hooks spec; included by
# Gemini/Tabnine/Devin which derive from the same protocol).
native_event = sys.argv[5] if len(sys.argv) >= 6 else ""
payload = sys.stdin.read() if not sys.stdin.isatty() else "{}"
project_root = Path(__file__).parent.parent.resolve()
@@ -307,14 +357,14 @@ def main():
from specify_cli.events import resolve_and_run_event_command
sys.exit(
resolve_and_run_event_command(
command_name, _event_name, payload, project_root, timeout=timeout
command_name, _event_name, payload, project_root, timeout=timeout, envelope=envelope, native_event=native_event
)
)
except ImportError:
except (ImportError, TypeError):
pass
# Fallback: self-contained stdlib resolver (one-time/temporary installs).
sys.exit(_run_inline(command_name, payload, project_root, timeout))
sys.exit(_run_inline(command_name, payload, project_root, timeout, envelope, native_event))
if __name__ == "__main__":
@@ -362,17 +412,21 @@ function resolveDispatcher(directory: string): void {{
) as string;
}}
function runEvent(command: string, event: string, input: any, output: any, timeoutSec: number): void {{
if (!DISPATCHER) return;
function runEvent(command: string, event: string, input: any, output: any, timeoutSec: number): string {{
if (!DISPATCHER) return '';
try {{
// execFileSync with an argv array invokes the interpreter directly — no
// shell — so command/event strings with metacharacters can't break out
// of the dispatcher argument (C9). The dispatcher arg is seconds; the
// execFileSync timeout is ms with a buffer so the outer cap fires after
// the dispatcher's inner subprocess (S3).
execFileSync(INTERPRETER, [DISPATCHER, command, event, String(timeoutSec)], {{
// the dispatcher's inner subprocess (S3). stdout is captured and
// returned so context-injection hooks (experimental.chat.system.transform,
// chat.message) can push it into their outputs; stderr stays inherited so
// dispatcher errors remain visible (C11).
return execFileSync(INTERPRETER, [DISPATCHER, command, event, String(timeoutSec)], {{
input: JSON.stringify({{ input, output }}),
stdio: ['pipe', 'inherit', 'inherit'],
stdio: ['pipe', 'pipe', 'inherit'],
encoding: 'utf-8',
timeout: (timeoutSec + {buffer}) * 1000,
}});
}} catch (e) {{
@@ -382,6 +436,12 @@ function runEvent(command: string, event: string, input: any, output: any, timeo
}}
}}
// Cache session_start handler output per sessionID so non-idempotent
// handlers (setup, telemetry, file-mutating scripts) run once per session
// instead of on every LLM request (experimental.chat.system.transform
// fires per LLM turn). Evicted on session.deleted.
const sessionStartCache = new Map<string, string>();
{event_entries}
export default (async ({{ client, project, directory, $ }}) => {{
@@ -585,12 +645,26 @@ def resolve_and_run_event_command(
project_root: Path,
*,
timeout: int = 120,
envelope: str = "plain",
native_event: str = "",
) -> int:
"""Core entry point to resolve and execute an event-driven command.
*timeout* is the per-handler timeout in seconds, passed through from the
native hook config via the dispatcher (S4) so a handler configured above
the previous fixed 120s cap can run for its full duration.
*envelope* selects how the handler's stdout is emitted for the agent's
context-injection protocol (C13): ``plain`` passthrough (Claude/Codex
inject plain stdout), ``hookSpecificOutput``/``additionalContext``/
``additional_context`` JSON wrappers (Gemini/Tabnine/Qwen/Devin, Copilot,
Cursor respectively), or ``suppress`` (strict-JSON agents on events whose
output can't be used).
*native_event* is the agent's native hookEventName (e.g. ``"SessionStart"``),
required inside ``hookSpecificOutput`` by Qwen's hooks spec (and included
by the Claude Code hooks spec Gemini/Tabnine/Devin derive from). Only
used when *envelope* is ``hookSpecificOutput``.
"""
template_path, ext_id = _find_command_template(command_name, project_root)
if not template_path:
@@ -610,7 +684,7 @@ def resolve_and_run_event_command(
cwd=str(project_root),
)
if result.stdout:
sys.stdout.write(result.stdout)
_emit_event_stdout(result.stdout, envelope, native_event)
if result.returncode != 0:
if result.stderr:
sys.stderr.write(result.stderr)
@@ -624,6 +698,35 @@ def resolve_and_run_event_command(
return 2
def _emit_event_stdout(output: str, envelope: str, native_event: str = "") -> None:
"""Write handler stdout in the agent's context-injection shape (C13).
Mirrors the ``_emit`` helper inside the generated dispatcher template;
keep both in sync. Empty output emits nothing under any envelope.
*native_event* is the agent's native hookEventName, required inside
``hookSpecificOutput`` by Qwen's hooks spec (and included by the
Claude Code hooks spec Gemini/Tabnine/Devin derive from).
"""
if not output:
return
if envelope == "suppress":
return
if envelope == "hookSpecificOutput":
payload = {"additionalContext": output}
if native_event:
payload["hookEventName"] = native_event
sys.stdout.write(json.dumps({"hookSpecificOutput": payload}) + "\n")
return
if envelope == "additionalContext":
sys.stdout.write(json.dumps({"additionalContext": output}) + "\n")
return
if envelope == "additional_context":
sys.stdout.write(json.dumps({"additional_context": output}) + "\n")
return
sys.stdout.write(output)
# -- Sourcing events map (CLI/Orchestration domain) -------------------------
# Resolved events map: each canonical event name maps to an *ordered list* of
@@ -1018,7 +1121,17 @@ def _dispatcher_command(
When *timeout_seconds* is given, the resolved timeout (in the
integration's native unit) is appended as a 4th argument so the dispatcher
and inner runner honor the per-handler timeout instead of a fixed 120s cap
that would kill a handler configured for longer (S4).
that would kill a handler configured for longer (S4). When omitted, a
default of 60s is emitted so the positional argument order
(command event timeout envelope native_event) stays aligned — otherwise
the envelope would land in the timeout slot and the dispatcher would
silently fall back to plain stdout.
When the integration declares a context-injection envelope for this
canonical event (``events_context_envelope``, C13), the envelope token is
appended as a 5th argument so the dispatcher wraps stdout in the JSON
shape the agent's hook protocol requires. Plain-passthrough agents
(Claude/Codex) declare no envelope and get no extra argument.
"""
if target_os == "host":
interpreter = _resolve_interpreter(project_root)
@@ -1038,16 +1151,44 @@ def _dispatcher_command(
# operator. Prefix & for the explicit windows target only.
prefix = "& " if target_os == "windows" else ""
base = f"{prefix}{q_interp} {dispatcher} {q_command} {q_event}"
if timeout_seconds is not None:
# R2: the dispatcher interprets this argument as seconds, so pass the
# raw seconds — NOT _native_timeout(...) (which converts to ms for
# Gemini/Qwen/Tabnine and would yield 60000 seconds). The buffer is
# applied to the native hook timeout field (in the adapter formatters)
# so the agent's outer cap fires after the inner subprocess timeout.
base += f" {_shell_quote(str(int(timeout_seconds)), target_os)}"
# Always emit the timeout (4th positional arg) so the dispatcher's argv
# parsing stays aligned when an envelope (5th) or native_event (6th)
# follows. Without it the envelope would land in the timeout slot and
# the dispatcher would fall back to plain stdout (R3).
resolved_timeout = 60 if timeout_seconds is None else int(timeout_seconds)
# R2: the dispatcher interprets this argument as seconds, so pass the
# raw seconds — NOT _native_timeout(...) (which converts to ms for
# Gemini/Qwen/Tabnine and would yield 60000 seconds). The buffer is
# applied to the native hook timeout field (in the adapter formatters)
# so the agent's outer cap fires after the inner subprocess timeout.
base += f" {_shell_quote(str(resolved_timeout), target_os)}"
envelope = _context_envelope_for(integration, event_name)
if envelope:
base += f" {_shell_quote(envelope, target_os)}"
# hookSpecificOutput requires the native hookEventName inside the
# envelope (Qwen's hooks spec marks it mandatory; the Claude Code
# hooks spec that Gemini/Tabnine/Devin derive from includes it).
# Append the native event name as a 6th dispatcher argument so the
# dispatcher can populate hookEventName in the JSON output.
if envelope == "hookSpecificOutput":
native_event = getattr(integration, "CANONICAL_TO_NATIVE", {}).get(event_name, "")
if native_event:
base += f" {_shell_quote(native_event, target_os)}"
return base
def _context_envelope_for(integration: IntegrationBase, canonical_event: str) -> str | None:
"""Resolve the context-injection envelope for an integration + event (C13).
The event key wins; ``"*"`` is the fallback. Returns ``None`` when the
integration declares no envelope for the event (plain stdout passthrough).
"""
mapping = getattr(integration, "events_context_envelope", None) or {}
if canonical_event in mapping:
return mapping[canonical_event]
return mapping.get("*")
def install_integration_events(
integration: IntegrationBase,
project_root: Path,
@@ -1590,6 +1731,14 @@ def _build_opencode_plugin(
an argv array (C9). Both the ``input`` and ``output`` callback arguments
are forwarded to ``runEvent`` (C7) so pre_tool_use can inspect tool
arguments and post_tool_use can inspect the result.
Context-injection natives get dedicated hook bodies: for
``experimental.chat.system.transform`` the handlers' concatenated stdout
is pushed into ``output.system`` (system-prompt injection, re-applied per
LLM request so the context survives compaction); for ``chat.message`` it
is pushed as a synthetic text part on the user message (C11). Other
natives keep their side-effect behavior (tool.execute.* args mutation;
session.* lifecycle events via the generic ``event`` hook).
"""
event_entries: list[str] = []
plugin_returns: list[str] = []
@@ -1604,11 +1753,16 @@ def _build_opencode_plugin(
ev_lit = json.dumps(ev)
native_lit = json.dumps(native)
is_injection = native in ("experimental.chat.system.transform", "chat.message")
# Build the body: one runEvent() call per handler wrapped in try/catch,
# forwarding both input and output (C7). An optional tool-name matcher
# guard applies to tool.execute.* hooks. All handlers execute before
# any aggregate error is thrown.
# any aggregate error is thrown. Injection hooks additionally collect
# each handler's stdout and return the concatenation.
body_lines: list[str] = [" const errors: string[] = [];"]
if is_injection:
body_lines.append(" const contexts: string[] = [];")
for cfg in handlers:
command = str(cfg.get("command", ""))
command_lit = json.dumps(command)
@@ -1630,6 +1784,10 @@ def _build_opencode_plugin(
body_lines.append(
f" try {{ runEvent({command_lit}, {ev_lit}, input, output, {timeout_sec}); }} catch (e) {{ errors.push((e as Error).message); }}"
)
elif is_injection:
body_lines.append(
f" try {{ const ctx = runEvent({command_lit}, {ev_lit}, input, output, {timeout_sec}); if (ctx) contexts.push(ctx); }} catch (e) {{ errors.push((e as Error).message); }}"
)
else:
body_lines.append(
f" try {{ runEvent({command_lit}, {ev_lit}, input, output, {timeout_sec}); }} catch (e) {{ errors.push((e as Error).message); }}"
@@ -1648,14 +1806,65 @@ def _build_opencode_plugin(
f" _{ev}(input, output);\n"
f" }},"
)
elif native == "experimental.chat.system.transform":
body_lines.append(' return contexts.join("\\n\\n");')
event_entries.append(
f"function _{ev}(input: any, output: any): string {{\n"
+ "\n".join(body_lines) + "\n"
" }"
)
# OpenCode fires experimental.chat.system.transform for non-session
# operations (e.g. agent generation) with no sessionID. Guard so
# canonical session_start handlers only run when a session is
# present, preventing their output from being injected into
# internal prompts. Cache the handler output per sessionID so
# non-idempotent handlers (setup, telemetry, file-mutating
# scripts) execute once per session instead of on every LLM
# request; the cache is evicted on session.deleted.
plugin_returns.append(
f" {native_lit}: async (input: any, output: any) => {{\n"
f" if (!input.sessionID) return;\n"
f" let ctx = sessionStartCache.get(input.sessionID);\n"
f" if (ctx === undefined) {{\n"
f" ctx = _{ev}(input, output);\n"
f" sessionStartCache.set(input.sessionID, ctx ?? \"\");\n"
f" }}\n"
f" if (ctx) output.system.push(ctx);\n"
f" }},"
)
elif native == "chat.message":
body_lines.append(' return contexts.join("\\n\\n");')
event_entries.append(
f"function _{ev}(input: any, output: any): string {{\n"
+ "\n".join(body_lines) + "\n"
" }"
)
# Part id must start with "prt" (opencode's Identifier brand): an
# invalid id fails the user-part schema validation and crashes the
# whole session (C12). Derive it from the last existing part so the
# brand survives an opencode prefix change, falling back to "prt_"
# when output.parts is empty.
plugin_returns.append(
f" {native_lit}: async (input: any, output: any) => {{\n"
f" const ctx = _{ev}(input, output);\n"
f" if (!ctx) return;\n"
f" const base = output.parts[output.parts.length - 1]?.id ?? \"prt_\" + Date.now().toString(36) + Math.random().toString(36).slice(2, 10);\n"
f" output.parts.push({{ id: base + \".speckit\" + Math.random().toString(36).slice(2, 8), sessionID: input.sessionID, messageID: output.message.id, type: \"text\", text: ctx, synthetic: true }});\n"
f" }},"
)
else:
event_entries.append(
f"function _{ev}(input: any, output: any) {{\n"
+ "\n".join(body_lines) + "\n"
" }"
)
# Evict the sessionStartCache when the session is deleted so the
# cache doesn't grow unbounded across sessions.
eviction = ""
if native == "session.deleted":
eviction = "if (event.sessionID) sessionStartCache.delete(event.sessionID); "
event_handlers.append(
f" if (event.type === {native_lit}) {{ _{ev}(event, event); }}"
f" if (event.type === {native_lit}) {{ {eviction}_{ev}(event, event); }}"
)
if event_handlers:
+14
View File
@@ -957,6 +957,20 @@ class IntegrationBase(ABC):
"""Return True if this integration supports agent-native events."""
return bool(getattr(self, "CANONICAL_TO_NATIVE", None) and getattr(self, "events_config_file", None))
# Context-injection envelope for hook stdout, keyed by canonical event
# (with "*" as the fallback). Not every agent injects a hook's plain-text
# stdout as model context: Gemini/Tabnine/Qwen/Devin are JSON-only
# protocols (plain text becomes user-facing noise), Copilot discards
# non-JSON stdout, and Cursor parses stdout as JSON. Values:
# "hookSpecificOutput" → {"hookSpecificOutput": {"additionalContext": ...}}
# "additionalContext" → {"additionalContext": ...} (top-level, Copilot)
# "additional_context" → {"additional_context": ...} (top-level, Cursor)
# "suppress" → emit nothing (strict-JSON agents on events whose
# output can't be used)
# Absent (no matching key and no "*") → plain stdout passthrough
# (Claude/Codex inject plain stdout; opencode injects via its TS plugin).
events_context_envelope: dict[str, str] = {}
# -- Convenience helpers for subclasses -------------------------------
def install(
@@ -152,6 +152,14 @@ class CopilotIntegration(IntegrationBase):
}
events_config_file = ".github/hooks/speckit.json"
events_format = "copilot-json"
# Copilot sessionStart and userPromptSubmitted inject a top-level
# additionalContext field into the model-facing prompt (C13). Non-JSON
# stdout is discarded harmlessly by Copilot on other events, so no other
# event needs an envelope.
events_context_envelope = {
"session_start": "additionalContext",
"user_prompt_submit": "additionalContext",
}
# Mutable flag set by setup() — indicates the active scaffolding mode.
_skills_mode: bool = True
@@ -48,6 +48,14 @@ class CursorAgentIntegration(SkillsIntegration):
}
events_config_file = ".cursor/hooks.json"
events_format = "json-flat"
# Cursor sessionStart injects a top-level additional_context (snake_case)
# field (C13). beforeSubmitPrompt has no context output field (block/allow
# only), and plain text on any hook fails Cursor's JSON parse — suppress
# everything else.
events_context_envelope = {
"*": "suppress",
"session_start": "additional_context",
}
def build_exec_args(
self,
@@ -44,6 +44,13 @@ class DevinIntegration(SkillsIntegration):
# top-level "hooks" wrapper (U2), unlike the settings.json formats. The
# json-root-nested writer/remover operate directly on the root event keys.
events_format = "json-root-nested"
# Devin's hooks protocol is JSON-stdout; additionalContext is the
# documented injection field for SessionStart/UserPromptSubmit (C13).
events_context_envelope = {
"*": "suppress",
"session_start": "hookSpecificOutput",
"user_prompt_submit": "hookSpecificOutput",
}
def build_exec_args(
self,
@@ -33,6 +33,15 @@ class GeminiIntegration(TomlIntegration):
}
events_config_file = ".gemini/settings.json"
events_format = "json-nested"
# Gemini mandates JSON-only hook stdout ("silence is mandatory"): plain
# text becomes a user-facing systemMessage, never context. Inject via
# hookSpecificOutput.additionalContext on the two context events and
# suppress stdout everywhere else (C13).
events_context_envelope = {
"*": "suppress",
"session_start": "hookSpecificOutput",
"user_prompt_submit": "hookSpecificOutput",
}
# Gemini measures hook timeouts in milliseconds, unlike Claude/Cursor/Codex
# which use seconds. The shared formatter converts via _native_timeout (#7)
# so the default 60s becomes 60000ms instead of terminating the dispatcher
@@ -23,7 +23,15 @@ class OpencodeIntegration(MarkdownIntegration):
CANONICAL_TO_NATIVE = {
"pre_tool_use": "tool.execute.before",
"post_tool_use": "tool.execute.after",
"session_start": "session.created",
# session_start maps to the system-prompt transform hook (not the
# session.created event) so the handler's stdout is injected into the
# system prompt — session.created has no output channel. The hook
# fires per LLM request, which keeps the context present across
# compaction at the cost of running the handler per turn.
"session_start": "experimental.chat.system.transform",
# user_prompt_submit maps to chat.message so handler stdout is
# injected as a synthetic text part on the user's message.
"user_prompt_submit": "chat.message",
"session_end": "session.deleted",
}
events_config_file = "opencode.json"
@@ -30,6 +30,12 @@ class QwenIntegration(MarkdownIntegration):
}
events_config_file = ".qwen/settings.json"
events_format = "json-nested"
# Qwen hooks are a JSON stdin/stdout protocol (Gemini-derived) (C13).
events_context_envelope = {
"*": "suppress",
"session_start": "hookSpecificOutput",
"user_prompt_submit": "hookSpecificOutput",
}
# Qwen Code's command hooks measure timeout in milliseconds (default
# 60000), per the Qwen Code hooks documentation. Declaring the unit makes
# the shared formatter convert the 60s default to 60000ms instead of
@@ -33,6 +33,12 @@ class TabnineIntegration(TomlIntegration):
}
events_config_file = ".tabnine/agent/settings.json"
events_format = "json-nested"
# Tabnine is Gemini-hooks-compatible (JSON-only stdout) (C13).
events_context_envelope = {
"*": "suppress",
"session_start": "hookSpecificOutput",
"user_prompt_submit": "hookSpecificOutput",
}
# Tabnine mirrors Gemini's hook schema (BeforeTool/AfterTool) and, like
# Gemini, measures hook timeouts in milliseconds. Declaring the unit makes
# the shared formatter convert the 60s default to 60000ms instead of