Skip to content

[Bug] Desktop hook payloads carry cwd="/" #4029

Open

Description

@mikehasa

Version

1.0.4 (desktop app, bundle id com.kimi.code.desktop), engine 2.1.1 inside it. Source references are against main @ be7d5f5fea7800778e4660cd5f36780ba783bddd (apps/kimi-code@2.1.1), which is the commit the 1.0.4 bundle was built from.

Plan

Kimi Code subscription through the managed:kimi-code provider (OAuth, oauth_host = "https://auth.kimi.ai").

Model

DeepSeek/deepseek-flash (default_model in ~/.kimi-code/config.toml).

Platform

uname -mprs → Darwin 24.6.0 arm64 arm (Apple M4 Max, 36 GiB).


What I'm seeing

On the desktop app, the cwd field of the hook payload is "/" for every agent-level hook event, while the session it belongs to has a real working directory.

cwd is a documented top-level payload field and is meant to be the project directory — docs/en/customization/hooks.md:69-75:

{
  "hook_event_name": "PreToolUse",
  "session_id": "session_abc",
  "session_title": "Fix the login page",
  "client_type": "kimi_code_cli",
  "cwd": "/path/to/project"
}

Where the value comes from

  1. The field is assembled in packages/agent-core-v2/src/features/externalHooks/internal/matchHooks.ts:44 (const cwd = args.cwd ?? '';) and inserted into the payload at :60-63, then camel→snake converted at :124-134 — which is why a plain grep hook_event_name over the source only finds the docs and tests, not the construction site.
  2. The value is whatever the caller passes: packages/agent-core-v2/src/features/externalHooks/app/externalHooksRunnerService.ts:92 → cwd: args.cwd ?? this.bootstrap.cwd.
  3. bootstrap.cwd is input.cwd ?? process.cwd() — packages/agent-core-v2/src/app/bootstrap/bootstrap.ts:123.
  4. Nothing can supply input.cwd on the desktop. ServerStartOptions (packages/kap-server/src/start.ts:99-127) has no cwd field at all, and the bootstrap({...}) call at start.ts:198-212 passes homeDir, configPath, env, clientIdentity and args — no cwd.
  5. The agent-level hook service never passes one either. In packages/agent-core-v2/src/features/externalHooks/agent/agentExternalHooksService.ts, the PreToolUse trigger (:304), UserPromptSubmit (:343), Stop (:416), PreCompact (:428) and everything routed through its fireAndForget helper (:125-132) all omit cwd.

So the fallback is used, and it is process.cwd() of whatever process hosts the engine. For the desktop app that is the Electron main process, whose process.cwd() is / (LaunchServices starts it with / as the working directory). Result: cwd is the string "/".

Affected events: PreToolUse, PostToolUse, PostToolUseFailure, PermissionRequest, PermissionResult, UserPromptSubmit, UserPromptQueued, Stop, StopFailure, Interrupt, PreCompact, PostCompact, Notification, TaskStarted, TurnStarted.

The session-scoped events are not affected — sessionExternalHooksService.ts:112/135/145/160/174 pass cwd: this.context.cwd, i.e. the session's workDir. Only the trigger sites that go through the runner's fallback are wrong.

This is not just a cosmetic payload field

matchHooks.ts:72 uses that same value as the hook process's working directory (cwd: hook.cwd ?? (cwd === '' ? undefined : cwd), consumed by runHook.ts:66-70 with shell: true, cwd). So a PreToolUse hook script runs in /, not in the project. Any hook that relies on a relative path, on os.getcwd(), or on the payload's cwd to locate the repository gets the wrong answer.

Measurements on this machine (desktop 1.0.4, no configuration changed)

  • lsof -a -p 1095 -d cwd for the app's main process → cwd is / (its fds 0/1/2 are /dev/null). This is the value that becomes bootstrap.cwd.
  • A whole-machine sampler (lsof -a -c python -d cwd in a tight loop, matching only hook command lines) caught 172 distinct hook subprocesses spawned by the engine in a 2 min 19 s window (2026-09-24 23:44:13Z–23:46:32Z): 172 / 172 ran with cwd = "/". The sessions that spawned them have real working directories (~/.kimi-code/session_index.jsonl → "workDir":"/Users/MacbookPro/Projects/<app-a>" and similar), and the app was the only Kimi Code engine running on the machine at the time.
  • A consumer already trips over it: the installed agent-acct hook derives a project label from the payload cwd (basename of the path). Its cached client context has project_label: null for 5 / 5 desktop sessions on this machine, which is what a cwd of / produces. It cannot fall back to a project name at all.

Steps to reproduce

  1. Add a PreToolUse hook that records both the payload field and its own working directory:

    [[hooks]]
    event = "PreToolUse"
    command = "/usr/bin/python3 -c 'import json,os,sys; d=json.load(sys.stdin); open(\"/tmp/hook-cwd.jsonl\",\"a\").write(json.dumps({\"payload_cwd\":d.get(\"cwd\"),\"process_cwd\":os.getcwd(),\"event\":d.get(\"hook_event_name\")})+\"\\n\")'"
    timeout = 10
  2. In a desktop session whose working directory is not /, run any tool call.

  3. cat /tmp/hook-cwd.jsonl → {"payload_cwd": "/", "process_cwd": "/", "event": "PreToolUse"} for every line, despite the session having a real workDir.

  4. For contrast, a SessionStart hook on the same session records the correct directory — the split between the two trigger paths is the bug.

Expected behavior

cwd should be the session's working directory (the workDir the session was created with), as documented, and hook scripts should be spawned in it.

Suggested fix (one of, or both):

  • Pass the session cwd at the agent-level trigger sites in agentExternalHooksService.ts (it already has this.sessionContext in scope, exactly like sessionExternalHooksService does).
  • Give ServerStartOptions a cwd and let bootstrap receive it, so a host that is not the CLI (desktop, service, daemon) can hand over a real base directory instead of silently inheriting its own process.cwd().

Notes

  • Read-only observation: lsof, ps, the local session index, and reading the shipped app.asar. No process was killed and no configuration was changed to produce this report (step 1 above is the repro for anyone who wants to confirm it on their own machine).
  • Found while investigating a separate, larger issue — a session's resident child processes are never reaped (terminal tasks are skipped at dispose, and reconcile() marks records lost without signalling the persisted pid). I filed that one separately as [Bug] Resident child processes survive session end: terminal tasks are skipped at dispose and reconcile never signals the recorded pid #4028 so this field bug is not buried in it.
  • Scope note: the bug is the fallback itself, not the desktop. Any host that starts the engine with a process.cwd() unrelated to the session (desktop, service, daemon) gets the same wrong value; only the desktop case is measured above.

Contribution

  • I am willing to submit a PR for this bug fix myself (please wait for maintainer approval in this issue first).

Per CONTRIBUTING.md I will not open the PR before a maintainer comments /approve here.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions