Add a warning log when the type assertion from sync.Map.LoadAndDelete fails in UnsubscribeEvents, per review suggestion. This makes a mismatched type observable for debugging.
Add 3 tests covering scenarios that previously panicked: 1) missing enabled key in settings 2) enabled field with non-bool type 3) teams_webhook with webhooks using map[string]any from JSON unmarshal
Address remaining review feedback: 1) Add HistoryTokens field to ContextUsage/ContextStats, showing history-only token count in /context and frontend UI alongside SummarizeAtTokens so users can see the actual summarization trigger comparison. 2) Remove .codebuddy/github-contribute/ state files accidentally included in the PR.
sync.Map.LoadAndDelete returns any; unprotected type assertion could panic if an unexpected type were stored. Add ok check to safely handle mismatched types.
Two type assertions in toChannelHashes could panic when channel config values had unexpected types from JSON unmarshal: 1) value[enabled].(bool) panics if the key is missing or not a bool 2) vv.(map[string]string) panics when JSON unmarshal produces map[string]any. Add ok checks to safely handle both cases.
When an incoming group message is received, the inbound context ChatID was set to the raw group number without the group: prefix. This caused the outbound reply to use send_private_msg instead of send_group_msg. Fix by using the prefixed chatID as inbound context ChatID. Closes#3002
The SDK renamed ReceiveIdTypeChatId to CreateMessageV1ReceiveIDTypeChatId
in v3.9.4. Update all 5 usages in feishu_64.go and bump the dependency
version.
This fixes the build failure for Dependabot PR #3005.
isProcessRunning() previously only checked whether a PID existed via signal(0)/OpenProcess, without confirming the process was actually picoclaw. When the PID was reused by an unrelated process (e.g., systemd-resolved after a kill -9), the gateway would refuse to start with 'already running'.
Add isPicoclawProcess() that verifies the process name matches picoclaw:
- Unix: reads /proc/<pid>/comm
- Windows: calls QueryFullProcessImageNameW
If the running process is not picoclaw, treat the PID file as stale and proceed with normal startup. Falls back to trusting the liveness check when identity verification is unavailable (e.g., /proc unreadable, API call fails).
Fixes#2720.
The workspace guard's absolutePathPattern regex matches /Beijing?T in commands like 'curl wttr.in/Beijing'. Since 'wttr.in' is not a recognized web scheme, the path was routed through workspace sandbox validation, which could block legitimate scheme-less URL usage (curl allows bare domains without http://).
Add detection for domain-like tokens preceding /path matches:
- looksLikeDomain: checks for dot-separated tokens that don't end with common file extensions (.py, .go, .exe, etc.)
- localPathExists: verifies the token does not exist as a local filesystem entry
This dual guard prevents the symlink bypass identified in PR #2965 review: if 'foo.bar' exists as a local symlink or directory, the path still undergoes full workspace validation.
Fixes#1042.
Replace 7 instances of ignored json.Marshal errors with proper error handling. Previously, if marshaling an ExecResponse failed, a nil byte slice would be silently converted to an empty string in the LLM response. Now each site returns ErrorResult with the marshal error message.
The /context command previously showed only the hard budget compression
threshold (contextWindow - maxTokens), which confused users who expected
to see the soft summarization trigger from summarize_token_percent.
This commit adds SummarizeAtTokens alongside the existing CompressAtTokens
so that both thresholds are visible:
- Compress at: contextWindow - maxTokens (hard budget, triggers proactive
compression when exceeded)
- Summarize at: contextWindow * summarizeTokenPercent / 100 (soft trigger,
matches maybeSummarize's threshold)
The fix updates the /context command output, the Web UI popover, and the
pico channel WebSocket payload.
Fixes#2968
The PromoteAliasHistory method previously promoted the first non-empty alias session into a new canonical session. When a user upgraded, the migrated main session contained old messages that were copied into every new Web UI session because agent:main:main is always the first alias.
Add isMainSessionAlias() to detect and skip the main session alias during promotion. Fixes#2972.
The Stop() method previously used a select/default pattern which was not
safe under concurrent calls — two goroutines could both pass the check
and attempt to close the same channel, causing a panic.
Replace with sync.Once to guarantee exactly-once close semantics,
matching the documented contract of being safe for concurrent use.
Review feedback: afjcjsbx
Previously, only timeout and network errors (matched via string
patterns) were retried. HTTP 500 server errors from
OpenRouter/OpenAI-compatible providers would fail the agent turn
immediately when no model fallback candidate was available.
This commit replaces the separate timeout/network retry branches
with a unified transientLLMRetryReason() helper that:
1. Uses providers.ClassifyError() to detect server_error (HTTP >=500),
timeout, network, and rate_limit errors
2. Falls back to the existing string-based detection for errors
not classified by the provider
A regression test (TestPipeline_CallLLM_HTTP5xxRetry) verifies that
HTTP 500 errors are retried and recover successfully.
This is a clean rebase of the approach originally proposed in #2768
by afjcjsbx.
This fixes issue #2943 where WeChat channel image requests to Zhipu
GLM-5-Turbo vision API failed with error code 1210 (parameter error)
without triggering the fallback mechanism.
Changes:
- Added error code 1210 pattern matching to formatPatterns
- This allows the fallback mechanism to recognize Zhipu API parameter
errors and fall back to alternative vision models
Closes#2943
The SessionManager's background cleanup goroutine previously had no
shutdown mechanism. Each call to NewSessionManager() started a ticker
goroutine that ran indefinitely. In tests, where multiple
SessionManagers are created, this caused goroutine leaks.
This commit adds a Stop() method that cleanly shuts down the background
cleanup goroutine via a channel. Stop() is safe to call multiple times.
All existing tests now call t.Cleanup(sm.Stop) to ensure cleanup.
Claude Opus 4.8 on Bedrock rejects the temperature inference parameter
with a ValidationException ("temperature is deprecated for this model").
buildConverseParams now takes the model id and omits temperature for
claude-opus-4-8* (matching both bare model ids and region-prefixed
inference profiles), logging when it does so. max_tokens and all other
models are unaffected.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add GetJob and improved UpdateJob to CronService with proper cloning,
schedule diffing, and next-run recomputation. Expose get/update actions
in the cron tool so agents can inspect and partially update jobs without
losing payloads or needing remove+add cycles. Includes access control
for remote channels and command safety gates.
OpenAI/Codex OAuth streams can return text through response.output_text.delta while the final response.completed payload has response.output set to null. That made PicoClaw report an empty model response even though the backend returned valid content.
Accumulate streamed output_text delta events during the Codex response stream and use them as a fallback when the parsed final response has no content. Add a regression test covering the null final output case from issue #2953.
The auxiliary message filtering introduced in #2892 incorrectly drops
tool_calls messages when there is an active stream or a tombstone from
a recently finished stream.
This causes tool_calls to not be delivered to the UI when users make
consecutive requests, as the second request's tool_calls message arrives
while the first request's stream is still active or within the 30-second
tombstone window.
The fix excludes tool_calls from auxiliary message filtering, since they
represent new tool invocations that must be delivered to the UI, not
stale auxiliary content like feedback or thoughts.
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
CronTool.ExecuteJob was calling ExecTool.Execute without setting
action='run' in the args map. ExecTool.Execute requires the action
field and returns ErrorResult('action is required') immediately when
it's missing. This caused all cron command jobs to silently fail.
Adds a test covering the command execution happy path.
Discord only downloaded audio attachments before passing them to the agent. Non-audio attachments (images, videos, files) were passed as raw Discord CDN URLs, which do not flow through resolveMediaRefs and are not serialized as vision inputs.
Download every attachment, store it in the MediaStore with Discord's filename and content type metadata, and emit a media placeholder tag that matches the attachment kind. This lets resolveMediaRefs replace the placeholder with the local path-bearing tag and encode supported images for vision-capable providers. If a download fails, keep the previous raw URL fallback.
- Persistence layer (jsonl.go addMsg/SetHistory) normalizes CreatedAt
when missing so the invariant is guaranteed at the storage boundary
- API layer (session.go) exposes created_at on all transcript message
types with session.updated fallback for legacy messages
- Frontend uses per-message timestamps when available
- messagesContentEqual ignores CreatedAt for tail-matching after
JSONL roundtrip
Fixes#2787
* feat: add request-scoped context policies
Add named turn profiles under agents.defaults so callers can opt into
per-request context and tool policies without changing default chat behavior.
Profiles can disable history, system context, skill prompts, or tools, and can
limit skills/tools with allow lists. Wire profile selection through Pico message
payloads, agent turn execution, Web chat selection, and Web visual config.
Reject invalid turn profiles before saving config through Web APIs and document
the new request context policy behavior.
* fix: address turn profile review blockers
* feat: simplify request context policy config
* fix: suppress tool prompt when turn tools are disabled
* fix: enforce turn profile tool restrictions
Add mimo-v2.5 (multimodal) and mimo-v2.5-pro to MiMo's CommonModels so
the WebUI recommends vision-capable models by default. mimo-v2.5 supports
image understanding while mimo-v2.5-pro is text-only.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* feat(chat,seahorse): persist and display model_name across history
* test(seahorse): fix lint regressions in repair coverage
* fix(pico): preserve model_name in live updates
* fix(pico): preserve model_name through live stream wrappers
* feat(models): unify provider metadata around backend catalog
- Move shared provider metadata and alias normalization into backend-owned provider catalog
- Expose display, fetch, auth, and default model metadata through /api/models provider_options
- Replace frontend static provider registry with catalog-driven selection, validation, grouping, and fallback rendering
- Treat provider default api_base as placeholder and effective fetch/test base while keep submitted api_base separate from derived defaults
- Add model page retry handling, touched locale updates, and provider metadata assertions in backend tests
* fix(models): canonicalize backend provider aliases and common models
* fix(models): restore deepseek common model recommendations
* Support streaming
* fix: stream pico reasoning updates
Route Pico reasoning through the active streamer and hide empty thought placeholders.
* fix: harden configured streaming delivery
* fix ci
* fix split issue
Export MakeBackup for external use, add ResetToDefaults function that
backs up current config, creates defaults, and preserves security
credentials. Add `picoclaw config reset` CLI command with --force flag.
* fix(pico): preserve image media across pico attachments and client
* * fix ci
* fix(pico): preserve text when client media parsing fails
- Skip non-inline Pico attachment URLs instead of treating them as invalid inline media
- Preserve pico_client text messages when malformed media payloads are received
- Add regression coverage for media.create, download attachments, and invalid media payloads
* fix lint
* add gemini web search provider
* fix(web): prefer free providers before Gemini in auto mode
* fix(web): expose gemini api key and model settings
* fix(web): prefer configured providers before Gemini in auto mode
* fix(web): satisfy gemini lint checks
* fix(web): address gemini provider review feedback
* test(web): align auto-provider expectations
* fix(web): let gemini ignore search range
* feat: improve model configuration workflows
Add model catalog browsing, provider registry with form validation,
model fetch/test dialogs, and enhanced model management UI.
- Add model catalog API and catalog-dialog component for browsing saved models
- Add provider-registry with auto-populated form fields per provider
- Add provider-combobox, fetch-models-dialog, test-model-dialog components
- Add model-validation for provider-aware model ID validation
- Add command and popover UI components
- Enhance edit-model-sheet with tool schema transform support
- Add anthropic to protocolMetaByName for correct default API base
- Apply NormalizeBaseURL to anthropic provider for consistent URL handling
- Add i18n keys for new model management features (en/zh)
* fix(web): prevent auto-fetch when API key is missing in fetch models dialog
When a provider requires an API key but none is set, the dialog now shows
the warning without triggering a doomed fetch attempt. Fetch is deferred
until the user provides a key.
* fix(web): add credential warning for catalog imports from remote providers
When importing models from a catalog entry whose provider requires an API
key, a yellow warning banner now informs users that credentials will need
to be configured after import.
* feat(web,api): test connection with real connectivity verification and unsaved form values
Add POST /api/models/test-inline endpoint that performs actual network
probes (GET /models) instead of just checking config. Frontend Test
Connection now uses current form values (not saved state) and is
available in both Add and Edit model flows.
* style(web): apply linter formatting across model config components
Normalize quote style, import ordering, and class name ordering as
reported by the project linter.
* fix(web,api): fix edit test connection false negative and gate fetch for unsupported providers
- handleTestInlineModel now accepts optional model_index to fall back to stored credentials when api_key is empty, fixing false negatives when testing edited models
- Add supportsFetch to provider registry and FETCHABLE_PROVIDER_KEYS derived set
- Gate Fetch Models button to only show for OpenAI-compatible and Ollama providers
- Add backend guard in handleFetchModels to reject unsupported providers with clear error
* fix: address review feedback on model config workflow
- Send explicit {} for empty extra_body/custom_headers fields so the
backend clears stored values instead of preserving them
- Merge backend provider_options with frontend PROVIDERS registry so
the provider picker reflects backend-supported providers and policy
fields (create_allowed, default_auth_method, auth_method_locked)
- Render provider combobox popover inside the sheet scroll container
to fix wheel events scrolling the sheet instead of the provider list
* feat(web,api): add provider selection, model form foundation, and validation
Split from PR #2752 (part 1 of 3).
Backend:
- CRUD model endpoints (list/add/update/delete/set-default)
- Provider metadata with default API bases and model provider options
- Model ID validation and normalization
- Anthropic default API base normalization
Frontend:
- Provider registry with metadata, labels, icons, and aliases
- Provider combobox with backend option merging
- Model field validation with provider-aware checks
- Redesigned add/edit model sheets with provider selection
- Dynamic imports for fetch/catalog/test dialogs (coming in PR2/PR3)
- i18n support for model configuration UI
Always route through classifySDKError to ensure resp.Body is
closed even when the API call succeeds.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Store QuoteToken for image, video, and sticker messages (not just text)
- Add webhook.LocationMessageContent case to forward as [location] placeholder
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Fix bodyclose linter errors by ensuring resp.Body is closed
after all *WithHttpInfo SDK calls.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Address review feedback:
- Use *WithHttpInfo SDK variants to get HTTP response status codes
- Map status codes via ClassifySendError (429→ErrRateLimit, 5xx→ErrTemporary, 4xx→ErrSendFailed)
- Fall back to ClassifyNetError for network-level failures
- Configure SDK with 30s timeout HTTP client
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix(channels): dismiss tool feedback animation when turn ends via ResponseHandled
When a tool sets ResponseHandled=true (e.g., send_file), the turn ends
without producing a final assistant response. This meant no outbound
message triggered FinalizeToolFeedbackMessage, leaving the animation
goroutine running indefinitely — editing the Feishu card every 3 seconds
with "." / ".." suffixes long after the tool had finished.
Fix: call DismissToolFeedback at "Tool output satisfied delivery" so the
tracker is cleared and the animation goroutine is stopped immediately.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(adapters): add DismissToolFeedback to channelManagerAdapter
The adapter must implement the new interface method added in the
previous commit, otherwise the package fails to compile.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(channels): pass InboundContext to DismissToolFeedback for topic-aware keys
Telegram forum topics use scoped tracker keys like "chatID/topicID",
resolved via ToolFeedbackMessageChatID with the InboundContext. The
previous nil context caused the lookup to fall back to the raw chatID,
missing the topic-scoped entry and leaving the animation goroutine
orphaned in forum-topic conversations.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* style: wrap long function signatures for golines
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(feishu): fix image download with API fallback and post image support
- Add Image.Get API fallback when MessageResource.Get fails (different
permission scope: im:resource vs im:message:readonly)
- Extract and download images from post (rich text) messages
- Extract images from interactive card messages
- Deduplicate post image keys across locales
- Add comprehensive tests for new helpers
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* feat(media): add image path tags alongside base64 for LLM file access
Images are still base64-encoded into msg.Media for multimodal LLMs,
but now also get [image:path] tags injected into message content so
the LLM knows the local file path for save/forward operations.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* refactor(media): only auto-inject images for tool results, not user messages
Channel-received images (role=user) now get path tags only, letting
the LLM decide whether to view via load_image or just operate on
the file. Tool result images (role=tool, e.g. load_image) are
base64-encoded into a synthetic user message appended after the tool
message, since many LLM APIs don't support image_url in tool messages.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix(media): preserve tool-message ordering for multi-tool-call scenarios
Move synthetic user message (carrying base64 tool images) to after the
entire contiguous tool-message block instead of immediately after each
tool message. This preserves the assistant→tool→tool ordering required
by OpenAI-compatible APIs.
Also fix load_image to use generic [image: photo] placeholder so
injectPathTags can properly replace it with the actual path.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix(test): update load_image test for [image: photo] placeholder
The test was checking ForLLM for the media:// ref, but load_image now
emits the generic [image: photo] placeholder instead.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix(media): match all channel image placeholders in injectPathTags
Different channels emit different placeholder formats — Telegram/Feishu
use [image: photo], WeCom/WeChat/Line use bare [image], QQ/Discord use
[image: <filename>]. The previous string-match code only handled
[image: photo], so for the other channels the path tag was appended as
a duplicate, producing content like "[image] [image:/path]".
Switch to per-type regex that matches all generic placeholder shapes
while leaving path tags ([image:/path]) untouched. Also fixes the same
issue for [audio], [video], [file] tags. Added test coverage for the
various placeholder shapes.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix(media): skip path tag append for JSON content (Feishu cards/posts)
When content is structured JSON (interactive cards, post messages),
injectPathTags now skips the fallback append — only placeholder
replacement is attempted. This prevents corrupting JSON payloads
like {"schema":"2.0",...} with appended [image:/path] tags.
Adds looksLikeJSON() helper and three test cases covering JSON
objects, arrays, and an end-to-end resolveMediaRefs scenario with
Feishu card content.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(media): prepend path tags for JSON content, narrow looksLikeJSON
Two fixes from code review:
1. looksLikeJSON now only checks for '{' prefix (not '['), avoiding
false positives on regular text like "[update] see attached".
2. For JSON content (Feishu cards/posts), path tags are prepended
before the JSON instead of being silently dropped. This ensures
the LLM can discover attached images via the path tag while the
JSON payload stays valid for downstream parsing.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Tests cover: text-only streaming with chunk accumulation, tool call
parsing with fragmented JSON, mixed text+tool responses, context
cancellation, invalid JSON fallback to raw payload, nil stream guard,
default finish reason, and all stop reason mappings.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Adds ConverseStream API support to the Bedrock provider, implementing
the StreamingProvider interface. Tokens flow via onChunk callback for
real-time delivery to streaming-capable channels.
- Extract buildConverseParams to share request logic between Chat and ChatStream
- Add converseStreamReader interface for testability
- Preserve raw payload in Arguments on JSON parse failure
- Ensure Function.Arguments is always valid JSON
- Streaming timeout only applied when explicitly configured
- Capture stream Close() errors for diagnostics
- Consistent "bedrock conversestream" / "bedrock:" log prefixes
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Add detection for 'unknown variant' + 'image_url' error pattern used by
DeepSeek and other strict providers when vision is not supported.
These providers reject the image_url field at the JSON schema level
rather than returning a semantic 'not supported' message.
* feat(tools): add cross-platform serial hardware tool
* feat(config): wire serial tool into runtime and dashboard
* hardware/serial: tighten validation and error handling
* hardware/serial: improve unix cancellation and timeout polling
* hardware/serial: improve windows I/O handling
* hardware/serial: fix darwin cross-compilation build
* docs(design): summarize hardware support and serial limits
* build: keep go generate on host during cross builds
* onboard: drop unrelated go generate change from serial work
* style(tools): wrap serial lines for golines
* refactor(pico): unify message kind handling of tool_calls and thought
* fix(pico): add legacy compatibility for thought payload in Send method
Co-authored-by: Copilot <copilot@github.com>
---------
Co-authored-by: Copilot <copilot@github.com>
- Return fmt.Sprintf fallback instead of {} on encoding errors to preserve visibility
- Normalize nil to empty map in FormatArgsJSON for consistent output
- Remove redundant nil check in toolFeedbackArgsPreview wrapper
- Update test expectation: nil args now return {} not null
- Use fmt.Sprintf fallback instead of {} on encoding errors
- Normalize nil args to {} in FormatArgsJSON for consistent output
- Update tests to expect {} instead of null for nil args
Based on PR #2670 review feedback from afjcjsbx
Add a non-blocking runtime publish path and switch hot-path publishers to it.
Enforce subscription timeout boundaries, keep ordered subscriber snapshots up to date on subscribe changes, expose all runtime kinds to process hooks, add safe log attrs for non-agent events, and close the gateway message bus on full shutdown.
Replace leftover SubTurnOrphanResultEvent and short subturn event references with runtime event kinds in comments, tests, and hook design notes.
Validation: GOCACHE=/tmp/picoclaw-go-cache go test ./pkg/agent -run TestSpawnSubTurn_OrphanResultRouting; make lint
Emit gateway.start, gateway.ready, and gateway.shutdown on the shared runtime event bus, while keeping reload events on the same helper path.
Update subturn architecture docs to refer to runtime event kinds instead of the removed agent EventBus names.
Validation: GOCACHE=/tmp/picoclaw-go-cache go test ./pkg/gateway ./pkg/events; GOCACHE=/tmp/picoclaw-go-cache go test ./pkg/bus ./pkg/channels ./pkg/mcp ./pkg/tools/integration ./pkg/events ./pkg/gateway; make lint
Remove the legacy EventKind/Event envelope mapping and let agent event emission build pkg/events.Event values directly.
Keep HookMeta as the shared hook metadata shape and preserve legacy observe string aliases by mapping them to runtime event kinds.
Validation: GOCACHE=/tmp/picoclaw-go-cache go test ./pkg/agent; make lint
Drop the old agent EventBus, SubscribeEvents/EventDrops public surface, legacy hook observer dispatch, and hook.event process notification path. Agent observations now flow through pkg/events runtime events.
Validation: go test ./pkg/agent; make lint
Move agent domain event payload structs out of the legacy event envelope file so the remaining EventKind/Event/EventMeta compatibility layer can be removed independently later.
Validation: go test ./pkg/agent; make lint
Use RuntimeEventObserver for the normal in-process hook observer path and make the process-hook helper assert hook.runtime_event notifications.
Validation: go test ./pkg/agent; make lint
Move AgentLoop event assertions to the runtime event stream and keep the legacy SubscribeEvents test only for dual-publish compatibility.
Validation: go test ./pkg/agent; make lint
Deprecate the legacy agent event APIs and add a runtime event test helper, then migrate the follow-up queued test to the runtime event stream.
Validation: go test ./pkg/agent; make lint
Migrate hook observation to runtime events and update the process hook notification protocol. Add runtime event publication for message bus failures, channel lifecycle/outbound flow, gateway reloads, MCP server state, and MCP tool calls.
Validation: go test ./pkg/events/... ./pkg/bus ./pkg/agent ./pkg/channels ./pkg/mcp ./pkg/tools/integration ./pkg/gateway; make lint
Introduce pkg/events with filtered channels, subscription policies, backpressure, and stats. Wire AgentLoop to dual-publish legacy agent events into runtime events while preserving old event APIs.
Validation: go test ./pkg/events/... ./pkg/agent; go test -race ./pkg/events/...; make lint