Commit Graph

4664 Commits

Author SHA1 Message Date
YeonGyu-Kim 03f04ca3da test(shared): isolate logger test overrides
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-05-18 13:24:08 +09:00
YeonGyu-Kim 21b782de93 test(plugin): cover fresh install team mode tools
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-05-18 13:24:08 +09:00
YeonGyu-Kim 9bae873395 fix(plugin): log team tool registry state
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-05-18 13:24:08 +09:00
YeonGyu-Kim e5653f1655 fix(config): log resolved team mode state
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-05-18 13:24:08 +09:00
YeonGyu-Kim 5555dbfc67 Merge pull request #4133 from code-yeongyu/fix/3816-frozen-output-args
fix: replace direct output.args mutation with replaceToolArgs helper (fixes #3816)
2026-05-18 13:09:30 +09:00
YeonGyu-Kim b20e2c9c55 fix: refactor 5 additional aliased output.args mutations + strengthen audit test
Address Oracle review feedback: refactor 4 aliased mutations via argsObject
in plugin/tool-execute-before.ts and 1 via toolOutput in atlas/tool-execute-before.ts.
Strengthen audit test regex to catch Output.args mutations regardless of the
variable name prefix (toolOutput, argsObject aliases).

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-05-18 13:04:10 +09:00
YeonGyu-Kim 3a63a8b205 test(shared): add audit test forbidding direct output.args mutation + fix 9th site
Add replace-tool-args.audit.test.ts that scans src/**/*.ts for direct
output.args property assignments and Object.assign(output.args, ...) outside
the helper. Also fix the 9th mutation site discovered by the audit in
compaction-todo-preserver/hook.ts.

Add replace-tool-args.test.ts with 12 regression tests covering both mutable
and Object.freeze'd output.args scenarios for all hook patterns.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-05-18 12:46:52 +09:00
YeonGyu-Kim 13d8857415 refactor(hooks): replace direct output.args mutation with replaceToolArgs (question/webfetch/null-byte)
Replace Object.assign(output.args, truncatedArgs), output.args.url = resolution.url,
and output.args.command = ... .replace(/\x00/g, "") with replaceToolArgs() calls
in question-label-truncator, webfetch-redirect-guard, and plugin/tool-execute-before.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-05-18 12:46:52 +09:00
YeonGyu-Kim af66b8de0b refactor(hooks): replace direct output.args mutation with replaceToolArgs (env + prompt injectors)
Replace output.args.command and output.args.prompt direct assignments
with replaceToolArgs() in non-interactive-env, prometheus-md-only,
and sisyphus-junior-notepad hooks.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-05-18 12:37:45 +09:00
YeonGyu-Kim 63a3021094 test(anthropic-effort): cover pre-set effort=max clamping for constrained providers (#3563)
Add 4 regression tests covering the pre-set effort path:

- pre-set effort=max + variant=max + github-copilot Opus -> clamped to high

- pre-set effort=max + variant=high + github-copilot Opus -> clamped to high (cubic violation case from PR #3608)

- pre-set effort=max + non-constrained Opus -> max preserved (no regression)

- pre-set effort=high + github-copilot Opus -> high preserved (don't overwrite valid pre-set)

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-05-18 12:36:56 +09:00
YeonGyu-Kim 96767b5d1a fix(anthropic-effort): clamp pre-set effort=max for constrained providers regardless of variant
Move the pre-set effort handling BEFORE the message.variant !== "max" early-return so that output.options.effort="max" set via session params or model-requirements fallback chains is always clamped to "high" on constrained providers (github-copilot, Anthropic OAuth), even when message.variant is not "max".

This addresses the cubic violation from PR #3608 where the clamp block was gated behind the variant-based return.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-05-18 12:36:45 +09:00
YeonGyu-Kim f72bb3fe8f refactor(hooks): replace direct output.args mutation with replaceToolArgs (claude-code-hooks)
Replace output.args.todos = parsed and Object.assign(output.args, result.modifiedInput)
with replaceToolArgs() calls that create a shallow clone instead of
mutating the potentially-frozen args object.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-05-18 12:33:32 +09:00
YeonGyu-Kim 6bffb2c40f feat(shared): add replaceToolArgs helper for safe tool-args mutation
opencode >=1.14 freezes output.args via Immer before plugin hooks run.
Direct property assignment or Object.assign on a frozen object throws
TypeError. This helper replaces output.args with a shallow clone
containing the patch, avoiding mutation of the frozen original.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-05-18 12:32:10 +09:00
YeonGyu-Kim 34e6af1ae6 docs: update AGENTS guidance 2026-05-18 11:49:56 +09:00
YeonGyu-Kim c4dd21e1f3 Merge pull request #4127 from code-yeongyu/fix/3997-notification-crash
fix(hooks): guard session-notification against missing ctx.$ (refs #3997)
2026-05-18 11:12:38 +09:00
YeonGyu-Kim baa0470e2f Merge pull request #3870 from cvqluu/fix/3772-log-bloat-and-epipe-suppression
fix(shared): cap log file growth via size-based rotation
2026-05-18 11:09:37 +09:00
YeonGyu-Kim 397ed1048a fix(hooks): guard session-notification against missing ctx.$ (refs #3997) 2026-05-18 10:47:27 +09:00
Chau Luu 4713a90816 fix(shared): cap log file growth via size-based rotation
Refs #3772 (the rotation half — EPIPE shutdown-noise suppression
remains a separate follow-up).

`src/shared/logger.ts` appends every entry to `os.tmpdir()/oh-my-opencode.log`
via `fs.appendFileSync` with no size cap. On long-running or busy projects
the file grows into the multi-GB range — a real-world reproduction on one
machine showed a 4.5 GB `oh-my-opencode.log.1` accumulated from per-shutdown
noise across many sessions. Eats `%TEMP%` on Windows and `/tmp` on Unix.

Add size-based rotation inside the existing batched `flush()` path:

  oh-my-opencode.log    → oh-my-opencode.log.1
  oh-my-opencode.log.1  → oh-my-opencode.log.2 (oldest dropped)

Cap is 50 MB per file; worst-case on-disk footprint is therefore ~150 MB.
The check runs only inside `flush()`, so the cost is amortized over
`BUFFER_SIZE_LIMIT` (50 entries) or the 500 ms flush timer. All filesystem
ops stay wrapped in try/catch — logging must never throw — and a failed
rotation leaves existing on-disk state intact rather than crashing the
agent. Pattern mirrors `src/openclaw/reply-listener-log.ts`, but with two
backup slots instead of one to keep a usable history window for debugging.

No config knobs in this iteration. The issue proposes `logs.max_size_mb`
/ `logs.max_files`, but the defaults are reasonable and adding schema is
more surface area than the bug warrants. Easy to promote later (the
existing test seams already let callers override the cap).

Tests:
- `src/shared/logger.test.ts` (new): under-threshold no-rotate, over-
  threshold rotates to `.1`, repeated rotation evicts oldest, rotation-
  failure-doesn't-throw, default path lives under `os.tmpdir()`. Uses a
  `mock.module(...)` substring marker so `script/run-ci-tests.ts` routes
  the file to its own bun process — the logger module's singleton state
  otherwise gets contaminated by sibling tests that mock `./shared`.

Out of scope: suppressing specific shutdown-noise messages (EPIPE,
`unhandledRejection received during shutdown cleanup`). The rotation
cap bounds the disk impact regardless of which noise pattern is
generating volume; per-message suppression can stand on its own
merits in a follow-up.
2026-05-17 19:46:29 +00:00
Claude Agent 3294128271 fix(background-agent): defer parent-wake when a user message just arrived (fixes #4120)
When a background subagent emits [ALL BACKGROUND TASKS COMPLETE], the
plugin queues a parent-wake that ultimately calls
dispatchInternalPrompt against the parent session. If the user submits
a new prompt inside the ~250 ms post-dispatch hold window, both writes
land on the same OpenCode session-storage file at the same instant.
OpenCode's @parcel/watcher (which the plugin itself does not depend on,
but does indirectly trigger) batches those events into a TSFN callback
and dispatches them into a JS env that the renderer has just torn down
because the session view re-mounted around the user's new message ->
napi_fatal_error / SIGABRT on macOS arm64. Removing the plugin removes
the parent-wake, which is why removing OmO eliminates the crash.

Mitigation:
- Before flushPendingParentWake calls dispatchInternalPrompt, inspect
  the parent session's message tail. If the most recent message is a
  user message added inside PARENT_WAKE_USER_MESSAGE_IN_PROGRESS_WINDOW_MS
  (default 2_000 ms), reschedule instead of dispatching. The user's own
  prompt will drive the model; queued notifications will be re-flushed
  on the next idle.
- Best-effort unref() of the long-lived pending-retry and dispatched-
  wake bookkeeping setTimeouts. They previously pinned the host event
  loop and prolonged the teardown window during which the watcher race
  can fire.

The new option userMessageInProgressWindowMs is wired through
BackgroundManager via a module-level constant and is independently
testable.

Regression test parent-wake-user-message-race.test.ts covers:
- fresh user message -> dispatch deferred
- latest message is assistant -> dispatch proceeds
- user message older than window -> dispatch proceeds
- window=0 disables the guard

This is a surface-level mitigation of the most-likely root cause from
the audit; a deeper fix (singleton guard against plugin
double-instantiation under @opencode-ai/plugin@local reload, dispose
lifecycle for OpenCode plugin reload) is out of scope here.
2026-05-17 21:27:11 +02:00
deopa0402 9758168676 test(auto-update): isolate cached version resolution
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-05-17 18:36:42 +09:00
deopa0402 37d9d613b6 fix(auto-update): clean stale OMO cache roots
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-05-17 18:35:29 +09:00
YeonGyu-Kim 7a3a0a031c test(tmux): ignore unrelated pane runner mock calls
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-05-17 17:21:35 +09:00
YeonGyu-Kim 0f92d2c98d test(prompt-gate): narrow audit binding detection
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-05-17 17:17:20 +09:00
YeonGyu-Kim 98df0a43e3 docs(prompt-gate): document unified dispatch invariant
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-05-17 17:16:18 +09:00
YeonGyu-Kim 6768decddb fix(session-recovery): fallback when stored unavailable-tool parts are absent
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-05-17 17:16:10 +09:00
YeonGyu-Kim 12bd658079 refactor(prompt-async-gate): remove deprecated dispatch wrappers
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-05-17 17:15:54 +09:00
YeonGyu-Kim 1bbe065c60 refactor(prompt-callers): migrate shared and cli dispatch
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-05-17 17:09:04 +09:00
YeonGyu-Kim 989ab7171d refactor(hooks): use unified internal prompt dispatch
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-05-17 17:07:35 +09:00
YeonGyu-Kim dd3fecaf40 refactor(plugin): use unified internal prompt dispatch
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-05-17 17:01:50 +09:00
YeonGyu-Kim fee515c5ac refactor(prompt-callers): migrate team and call_omo_agent dispatch
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-05-17 16:48:06 +09:00
YeonGyu-Kim df198d8b2d refactor(background-agent): use unified internal prompt dispatch
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-05-17 16:44:56 +09:00
YeonGyu-Kim a42f894f88 refactor(prompt-async-gate): collapse dispatch into mode-based entrypoint
Use one dispatchInternalPrompt surface with mode: async | sync so source, settle, hold, timeout, status checks, reservations, and release semantics stay in one runner. Keep the old helper names temporarily so caller migration can land atomically in follow-up commits.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-05-17 16:37:59 +09:00
YeonGyu-Kim b5d24619c8 test(prompt-async-gate): pin unified internal prompt dispatch contract
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-05-17 16:34:05 +09:00
YeonGyu-Kim 8bc4977563 fix(slash-command): skip already tagged command output 2026-05-17 16:17:42 +09:00
YeonGyu-Kim 55312cc4b6 fix(session-recovery): preflight idle recovery fanout 2026-05-17 16:17:36 +09:00
YeonGyu-Kim 1fea761cf2 Merge pull request #4106 from code-yeongyu/code-yeongyu/fix-stale-tool-hang
fix(session-recovery): recover interrupted idle tool turns
2026-05-17 15:51:15 +09:00
YeonGyu-Kim a7b7ace7ed fix(prompt-gate): block prompts into pending tool turns 2026-05-17 15:42:58 +09:00
YeonGyu-Kim 6eb88a0545 fix(session-recovery): prefer valid tool use ids 2026-05-17 15:15:13 +09:00
YeonGyu-Kim 4d417a33b6 fix(process-cleanup): stop force-exiting opencode on transient unhandled errors
Root cause of the user-visible `/init-deep ulw` hang (session
`ses_1cb9c3013ffesUOy5H3QOIya4K`): the plugin's `unhandledRejection` and
`uncaughtException` listeners were calling
`scheduleForcedExit(handler(error), 1, true)`, which both ran the entire
`cleanupAll()` chain (BackgroundManager shutdown, tmux pane closure,
team-mode teardown) and then `process.exit(1)`'d the host. Under heavy
slash commands like `/init-deep ultrafucking deep`, a single mid-stream
error (e.g. opencode's own `session.processor` Aborted-process condition,
or a transient socket reset) would:

  1. trigger the listener,
  2. abort the in-flight background tasks (`session.error
     MessageAbortedError` for both child sessions in the log),
  3. close the tmux panes the user was watching,
  4. immediately kill the host via `process.exit(1)`.

From the user's seat that looked like a frozen TUI, which is what they
reported as "ulw 여전히 멈추는데". The error blob also logged as `{}`
because `JSON.stringify(new Error(...))` strips non-enumerable Error
fields, so the previous log line carried no diagnostic value.

This change makes the global `uncaughtException` / `unhandledRejection`
listeners log-only:

* New `describeProcessCleanupError()` extracts `{name, message, stack}`
  from Error instances, falls back to a structured `{raw: ...}` payload
  for plain objects / primitives, so the log now actually says what
  failed.
* `registerErrorEvent()` no longer runs cleanup and no longer calls
  `scheduleForcedExit`. It detaches itself, logs a single explanatory
  line, and returns. Bun's default crash behaviour is already suppressed
  for these events when a listener is present, so the host now genuinely
  survives transient streaming errors instead of being killed by our
  own helper.
* Signal handlers (`SIGINT` / `SIGTERM` / `SIGBREAK` / `beforeExit` /
  `exit`) keep their existing behaviour and still run `cleanupAll()`
  before the host terminates — that is now the only path that tears
  down background tasks and tmux panes.

Tests are updated to lock in the new contract:

* New regression `#given scheduleForcedExit enabled AND unhandledRejection
  fires #when the listener runs #then process.exit is NOT called AND
  process.exitCode stays 0 AND no cleanup runs` (and the
  uncaughtException twin) re-enables `scheduleForcedExit`, spies on
  `process.exit` plus `globalThis.setTimeout`, and asserts none of them
  are touched. Without the fix this test failed exactly like the
  observed hang (exit called once, exitCode set to 1).
* The existing "manager shuts down before process exits" tests are
  rewritten to assert the opposite: cleanup is NOT invoked from the
  error path.
* A complementary `'exit'` listener test pins the real shutdown
  contract (`exit` event still triggers `cleanupAll`).
* A new `#given describeProcessCleanupError` block covers the four
  shapes (Error, plain object with own fields, empty object, primitive).
* The unregister assertion is tightened to check the listener count
  drops back to baseline (previously it relied on a side effect of the
  old cleanup-on-error path).

Manual QA: `bun /tmp/process-cleanup-smoke-test.ts` (out-of-tree smoke
driver) emits five back-to-back unhandledRejection/uncaughtException
events with various payload shapes and prints
`SMOKE_TEST_OK survived 5 emissions; exitCode=0; shutdownInvocations=0`,
confirming the host survives and no spurious cleanup runs.

`bun test` runs green for the affected modules:
  - src/features/background-agent (528 tests)
  - src/create-managers + src/plugin (218 tests)
  - src/hooks/{unstable-agent-babysitter,ralph-loop,keyword-detector}
    (225 tests)
2026-05-17 15:11:44 +09:00
YeonGyu-Kim f43effb842 fix(session-recovery): recover interrupted idle tool turns 2026-05-17 15:08:39 +09:00
YeonGyu-Kim fbec112bc2 fix(background-output): bound session.messages fetch to stop forever-hang during /init-deep
Root cause: `formatTaskResult` and `formatFullSession` both call
`client.session.messages({ path: { id: task.sessionId } })` with no
timeout. The OpenCode SDK explicitly disables fetch timeout
(`req.timeout = false` in `packages/sdk/js/src/client.ts:37`), so when
`session.processor` enters an "Aborted process" loop (same condition
patched in #4086 for the Read hot path) every subsequent
`session.messages` call hangs forever.

User-visible impact: the parent agent running a heavy slash command
such as `/init-deep` fires many background `task` agents in parallel
and then calls `background_output(task_id, block=true)` for each. The
existing `timeoutMs` (max 10min) caps only the polling loop that waits
for the child task to transition out of `running`. Once the child is
`completed`, the loop exits and the code falls through to
`formatTaskResult` / `formatFullSession`. The 10min cap does not apply
to that post-completion fetch, so a wedged `session.messages` leaves
the tool call hanging indefinitely. The parent session appears stuck
to the user (the symptom they report on `/init-deep ultrafucking
deep`).

Fix: race the underlying `session.messages` call against a 5s timeout
through a new `withSdkCallTimeout` helper local to
`src/tools/background-task/` (mirrors `withFetchTimeout` in
`src/shared/dynamic-truncator.ts` and `withDispatchTimeout` in
`src/shared/prompt-async-gate.ts`). On timeout the caller returns a
clearly-marked `"Error fetching messages: ... timed out after 5000ms"`
fallback so the agent can recognise the failure and continue instead
of waiting forever.

Tests:
- New `sdk-call-timeout.test.ts` pins the fix with three BDD cases
  using a never-settling mock and the new
  `_setBackgroundOutputFetchTimeoutMsForTesting(50)` override:
  (1) `formatTaskResult` resolves to the timeout fallback under the
  fetch budget, (2) same for `formatFullSession`, (3) two parallel
  callers against the wedged client both resolve cleanly.
- All three failed (timed out) before the fix and pass in 51ms each
  after.

Verification:
- `bun test src/tools/background-task/` -- 33 pass.
- `bun test` (full suite) -- 7014 pass, 1 skip, 0 fail across 723
  files.
- `bun run typecheck` -- clean (tsgo --noEmit).
- LSP diagnostics on the four changed files -- 0 errors.
- Manual QA harness `.local-ignore/init-deep-hang-qa.ts` (gitignored)
  drove production default 5s timeout against a wedged client:
  serial `formatTaskResult` resolved in 5001ms, serial
  `formatFullSession` in 5002ms, 10 parallel `formatTaskResult` calls
  all resolved within 5002ms with the timeout fallback. Without the
  fix every call hangs indefinitely.

Refs: builds on #4086 (dynamic-truncator) which patched the Read hot
path of the same SDK hang.
2026-05-17 14:23:16 +09:00
YeonGyu-Kim f4f1efcb6f fix(call-omo-agent): fail fast on lost prompts
Detect OpenCode promptAsync calls that return before a child session has any durable message, and surface a prompt acceptance error before the generic five-minute sync poll timeout.

Add a failing-first regression for the idle zero-message case and keep the existing durable-message completion path covered.

Debugging-Journal: .debugging
2026-05-17 13:57:12 +09:00
YeonGyu-Kim 75223149dd Merge pull request #4096 from code-yeongyu/kimi-k2.6
fix: add merge-conflict guard test to prevent source file corruption
2026-05-17 04:32:40 +09:00
YeonGyu-Kim d8f365bfd4 test(guard): add merge-conflict guard to prevent unresolved git conflicts in source files
Unresolved git merge conflict markers (<<<<<<<, =======, >>>>>>>) in
TypeScript source files break parsing and can cause the plugin to fail
at runtime or tests to hang with cryptic errors. This guard scans all
.ts/.tsx/.json files under src/ and fails the test suite if any
conflict markers are found.

Closes #debugging-hang-investigation
2026-05-17 04:17:45 +09:00
YeonGyu-Kim 38702f6e85 Merge pull request #4094 from code-yeongyu/fix/opus-4.7
fix(dynamic-truncator): bound session.messages fetch to stop forever-hang on Read (#4086)
2026-05-17 03:57:28 +09:00
YeonGyu-Kim 67ead7bf6d fix(dynamic-truncator): bound session.messages fetch to stop forever-hang on Read (#4086)
Root cause: `getContextWindowUsage` caches the *promise* of
`fetchContextWindowUsage` in a per-session WeakMap keyed by client. When
`ctx.client.session.messages({ path: { id: sessionID } })` never settles
(observed once `service=session.processor ... error=Aborted process`
takes hold), the cached pending promise wedges every concurrent and
later caller in the same session. The five hooks that share one
`createDynamicTruncator(ctx)` -- directory-agents-injector,
directory-readme-injector, rules-injector, tool-output-truncator, plus
indirect callers -- all await that same poisoned promise on every Read,
so the user-facing tool chain hangs forever and ESC cannot break it.
Reporters in #4086 land on this path consistently when reading AGENTS.md
files (which trigger directory-agents-injector via the directory walk).

Fix: race the underlying `session.messages` call against a 5s timeout
through a new `withFetchTimeout` helper. On timeout the catch block logs
the failure and returns `null`, which `dynamicTruncate` already treats
as the "context usage unavailable" signal and falls back to the static
truncation budget. Successful responses still cache as before. The
`message.updated finish=true` invalidation hook still clears poisoned
caches on the next completed turn so retries are clean.

Tests:
- Add a never-settling `session.messages` mock with a 50 ms override via
  the new `_setContextWindowUsageFetchTimeoutMsForTesting` hook (matches
  the established `_setXxxForTesting` pattern in `opencode-http-api.ts`
  and `prompt-async-gate.ts`).
- Three new BDD cases pin the fix: (1) single caller returns null fast,
  (2) parallel concurrent callers all unblock on the same cached promise
  instead of hanging, (3) invalidate + retry rehydrates cleanly.
- All 8 pre-existing tests in the file still pass (happy paths, cache
  reuse, invalidation, env/model fallback).

Verification:
- `bun test src/shared/dynamic-truncator.test.ts` -- 11 pass.
- `bun test src/shared/prompt-async-route-audit.test.ts` -- 6 pass
  (added log import, no raw prompt route added).
- `bun test` (full suite) -- 7009 pass, 1 skip, 1 pre-existing flake in
  `closeTmuxPane` mock.module test (reproduces on dev without this
  change; isolated run passes).
- `bun run typecheck` -- clean.
- `bun run build` -- clean (esm bundle + tsc + schema).
- Manual harness `.debugging/manual-qa.ts` (uncommitted) drives the same
  shape as the real hook chain and resolves the hang scenario in 51 ms.
2026-05-17 03:51:56 +09:00
YeonGyu-Kim fcd0011a6b test(atlas): track active timers instead of scheduled delays in setTimeout mock
- Replace the simple scheduledDelays array with an activeTimers Map so
  that clearTimeout removes timers from the tracked set.
- This prevents false positives when internal withDispatchTimeout calls
  setTimeout for safety timeouts that are immediately cancelled.
- Keeps the test intent unchanged: only genuinely scheduled retries are
  counted as delayed duplicate retries.
2026-05-17 03:50:02 +09:00
YeonGyu-Kim 2613de522f fix(prompt-async-gate): timeout isSessionActive to prevent infinite hang on stale SDK status
- Wrap isSessionActive in withDispatchTimeout (capped at 5s) so a
  stuck OpenCode SDK status() call cannot block internal prompts forever.
- Catch the timeout and treat session as inactive so the prompt can
  proceed rather than hanging indefinitely.
- Add regression test: session.status that never resolves now times out
  and allows dispatch instead of hanging the test (and production).

Refs: AGENTS.md internal-message-injection safety note
2026-05-17 03:41:08 +09:00
YeonGyu-Kim 169e61f775 test(audit): allowlist build-team-idle-wake-hint-client.ts in prompt route audit
Red: a43215f24 introduced plugin/build-team-idle-wake-hint-client.ts which
    accesses session.promptAsync for method binding. The audit test flagged it
    as a raw prompt route offender, breaking CI on dev.

Green: Add the narrow client facade to RAW_PROMPT_ALLOWLIST with the same
       justification pattern used for event.ts and recover-unavailable-tool.ts.
       The facade binds SDK methods back to the Session instance and performs
       no direct dispatch itself; all downstream calls flow through the shared
       prompt-async gate.

Verification: bun test src/shared/prompt-async-route-audit.test.ts passes
(6 pass, 0 fail, offenders list empty).
2026-05-17 03:10:02 +09:00
YeonGyu-Kim a43215f24e fix(plugin/event): bind team-idle-wake-hint client methods to SDK Session
The team-mode wiring at `createEventHandler` extracted
`pluginContext.client.session.promptAsync` and `.status` into a fresh
wrapper object. The methods were copied by reference, so the prompt-async
gate's `session.promptAsync.bind(session)` was binding to that plain
wrapper rather than the underlying SDK `Session` instance. The opencode
SDK's `promptAsync` reads `this._client.post(...)`, so production calls
threw `TypeError: undefined is not an object (evaluating 'this._client')`
on the very first dispatch — fingerprinted in /tmp/oh-my-opencode.log as
688 `background-agent-parent-wake`, 47 `model-suggestion-retry`, and 4
`team-idle-wake-hint` failures over the past three days.

Move the wrapper construction into `buildTeamIdleWakeHintClient`, which
preserves the narrow factory contract while binding both methods back to
the SDK `Session` so `_client` survives the dispatch. Cover the contract
with four BDD-style tests including the historical destructure-only
failure mode so any future regression is caught at unit-test time.
2026-05-17 02:06:52 +09:00