Skip to content

feat(session): expose runtime identity for external observers - #26149

Closed
yeelam-gordon wants to merge 5 commits into
google-gemini:mainfrom
yeelam-gordon:feat/runtime-status-sidecar
Closed

feat(session): expose runtime identity for external observers#26149
yeelam-gordon wants to merge 5 commits into
google-gemini:mainfrom
yeelam-gordon:feat/runtime-status-sidecar

Conversation

@yeelam-gordon

@yeelam-gordon yeelam-gordon commented Apr 28, 2026

Copy link
Copy Markdown

Summary

Adds a small JSON runtime-identity sidecar at <session-temp>/runtime.json while a Gemini CLI session is alive, so external tools can answer:

"Is there a gemini-cli process currently running against session X?"

The file records (pid, sessionId, workDir, hostname, startedAt, geminiCliVersion). Consumers parse the file and verify the recorded PID is alive (OS-specific). It is the most reliable cross-platform signal — argv does not embed the session id for fresh (non---resume) launches, and OS process titles get truncated.

Use cases:

  • Terminal multiplexers / tab managers tagging panes with the live session id.
  • IDE integrations correlating an editor instance with a running CLI session.
  • Observability daemons listing live sessions.

Prior art

Peer interactive agent CLIs already expose a per-session on-disk surface that external tooling has come to rely on, so the ecosystem expects it:

  • GitHub Copilot CLI — keeps per-session state under ~/.copilot/session-state/<sessionId>/, and the running shell session is discoverable per session id from that layout.
  • Claude Code CLI — writes per-project session transcripts to ~/.claude/projects/<encodedCwd>/<sessionId>.jsonl, with the running process discoverable per project.
  • OpenAI Codex CLI — emits a rollout file (e.g. ~/.codex/sessions/.../rollout-*.jsonl) whose header records the session id for the active process.

This PR gives gemini-cli an equivalent lightweight surface — a single JSON file per session — without adopting any one of those formats verbatim. Snake_case keys keep the file format language-agnostic and easy to consume from external tooling written in any language.

Sidecar contract

Path: <global-gemini-temp>/<projectShortId>/<sessionId>/runtime.json (parallels the existing per-session subdirs plans/, tracker/, tasks/).

JSON shape (snake_case for cross-tool consumption):

{
  "schema_version": 1,
  "pid": 12345,
  "session_id": "uuid",
  "work_dir": "/path/to/workspace",
  "hostname": "machine",
  "started_at": 1714329600.123,
  "gemini_cli_version": "0.40.0"
}

External consumers:

  1. Glob ~/.gemini/tmp/*/*/runtime.json.
  2. Parse, take pid.
  3. Verify the PID is alive — the record only proves some gemini-cli process once claimed this session, not that it is still running.

Lifecycle

  • Written on session start (clean launch and --resume). Resume case is an atomic overwrite via tmp + fsync + rename.
  • Not cleared on quit or crash. From an observer's standpoint the recorded PID no longer exists in either case, so a liveness check is sufficient and explicit cleanup adds nothing.
  • Refreshed on every same-PID session swap (/clear, session browser resume) — Config.setSessionId clears the outgoing sidecar and writes a fresh one for the incoming session, gated on a runtimeStatusEnabled ownership flag set by the interactive UI bootstrap so non-interactive paths can't trample a sibling shell's sidecar.
  • Removed naturally when the surrounding session directory is deleted.
  • Concurrent same-session resume → last-writer-wins (latest live owner).

Hook point

After the --list-extensions / --list-sessions / --delete-session early-exit branches and before all three session-serving paths (interactive, ACP, non-interactive). Utility commands don't drop a false sidecar.

The write is best-effort, wrapped in try/catch with debugLogger.debug. A failure never blocks startup.

Files touched

  • New packages/core/src/utils/runtimeStatus.tswriteRuntimeStatus, readRuntimeStatus, clearRuntimeStatus.
  • New packages/core/src/utils/runtimeStatus.test.ts — 22 unit tests (vitest, fs.mkdtemp).
  • packages/core/src/config/storage.ts — added getSessionTempDir() and getSessionRuntimeStatusPath().
  • packages/core/src/config/config.ts — added runtimeStatusEnabled ownership flag, markRuntimeStatusEnabled(), swap-time refresh inside setSessionId.
  • packages/core/src/index.ts — exported the new module.
  • packages/cli/src/gemini.tsx — wired the initial write and ownership-flag flip.

Test coverage (all 22 passing)

  • write writes expected fields and creates the session dir if missing
  • atomic on success (no .tmp leftover)
  • cleanup on rename failure leaves no leftover
  • read round-trips on success
  • null on missing / malformed JSON / unknown schema
  • non-ASCII (Chinese) UTF-8 round-trips and is preserved literally on disk
  • invalid UTF-8 bytes return null (strict TextDecoder)
  • type guards reject null session_id, string pid, non-integer pid, array work_dir, bool pid, top-level array, top-level null
  • atomic overwrite on resume
  • gemini_cli_version populated from CLI_VERSION
  • clearRuntimeStatus removes existing file, idempotent on missing file, no-op on missing dir

Plus a config integration test covering the same-PID swap behaviour: pre-flag no-op, post-flag clear+write, idempotent same-id call.

Risk review

  • Compatibility: per-session dir layout already used for plans/, tracker/, tasks/. Cleanup uses recursive rm so the extra file does not break deletion. listProjectChatFiles and SessionSelector both walk chats/ and are unaffected. No circular-import risk (runtimeStatus only imports version).
  • Regression: existing storage and config tests pass. The pre-existing failures in gemini.test.tsx reproduce on main and are unrelated.
  • Performance: writeRuntimeStatus uses fully async fs.promises.* (no event-loop blocking). One fsync + rename per session start, on the startup hot path but only once and best-effort.
  • Security: file is mode 0o600 (owner-rw, matches existing trust.ts and OAuth credential storage). Temp file uses 'wx' (O_CREAT | O_EXCL) for defense-in-depth against pre-placed symlinks. Contents (pid, sessionId, workDir, hostname) are no more sensitive than what already lives in ~/.gemini/projects.json. sessionId originates from randomUUID() and is sanitized in session-artifact paths.

Verification

  • npm run typecheck -w @google/gemini-cli-core
  • npm run typecheck -w @google/gemini-cli
  • npx eslint on touched files ✅
  • npm test -w @google/gemini-cli-core -- src/utils/runtimeStatus.test.ts → 22/22 ✅
  • npm test -w @google/gemini-cli-core -- src/config/storage.test.ts
  • npm test -w @google/gemini-cli-core -- src/config/config.test.ts ✅ (incl. new swap-behaviour test)
  • npm test -w @google/gemini-cli -- src/ui/commands/clearCommand.test.ts src/ui/hooks/useSessionBrowser.test.ts
  • npm run build -w @google/gemini-cli

@yeelam-gordon
yeelam-gordon requested a review from a team as a code owner April 28, 2026 23:32
@google-cla

google-cla Bot commented Apr 28, 2026

Copy link
Copy Markdown

Thanks for your pull request! It looks like this may be your first contribution to a Google open source project. Before we can look at your pull request, you'll need to sign a Contributor License Agreement (CLA).

View this failed invocation of the CLA check for more information.

For the most up to date status, view the checks section at the bottom of the pull request.

@gemini-code-assist

Copy link
Copy Markdown
Contributor

Summary of Changes

Hello, I'm Gemini Code Assist1! I'm currently reviewing this pull request and will post my feedback shortly. In the meantime, here's a summary to help you and other reviewers quickly get up to speed!

This pull request enhances the Gemini CLI by providing a standardized way for external applications to identify and monitor active CLI sessions. It achieves this by writing a small, self-describing JSON file at the start of each session, containing crucial runtime information. This enables better integration with third-party tools like terminal multiplexers and IDEs, allowing them to correlate running processes with specific Gemini CLI sessions. The implementation focuses on reliability, atomic operations, and minimal performance impact, ensuring that the CLI remains robust while offering increased observability.

Highlights

  • External Session Observability: Introduced a new mechanism to expose Gemini CLI session runtime identity to external tools by creating a runtime.json sidecar file for each active session.
  • Runtime Status File: The runtime.json file, located at <session-temp>/runtime.json, records essential session details such as PID, session ID, working directory, hostname, start time, and CLI version.
  • Atomic File Operations: Implemented atomic write operations for the runtime.json file using temporary files, fsync, and rename to ensure data integrity and prevent partial writes.
  • New Storage Utilities: Added new utility functions getSessionTempDir() and getSessionRuntimeStatusPath() within the storage configuration to manage session-specific temporary directories and file paths.
  • Comprehensive Testing: Included extensive unit tests (18 new tests) for the runtime status functionality, covering various scenarios including file creation, reading, data validation, and atomic overwrites.
Using Gemini Code Assist

The full guide for Gemini Code Assist can be found on our documentation page, here are some quick tips.

Invoking Gemini

You can request assistance from Gemini at any point by creating a comment using either /gemini <command> or @gemini-code-assist <command>. Below is a summary of the supported commands on the current page.

Feature Command Description
Code Review /gemini review Performs a code review for the current pull request in its current state.
Pull Request Summary /gemini summary Provides a summary of the current pull request in its current state.
Comment @gemini-code-assist Responds in comments when explicitly tagged, both in pull request comments and review comments.
Help /gemini help Displays a list of available commands.

Customization

To customize Gemini Code Assist for GitHub experience, repository maintainers can create a configuration file and/or provide a custom code review style guide (such as PEP-8 for Python) by creating and adding files to a .gemini/ folder in the base of the repository. Detailed instructions can be found here.

Limitations & Feedback

Gemini Code Assist may make mistakes. Please leave feedback on any instances where its feedback is incorrect or counter productive. You can react with 👍 and 👎 on @gemini-code-assist comments. If you're interested in giving your feedback about your experience with Gemini Code Assist for GitHub and other Google products, sign up here.

Footnotes

  1. Review the Privacy Notices, Generative AI Prohibited Use Policy, Terms of Service, and learn how to configure Gemini Code Assist in GitHub here. Gemini can make mistakes, so double check it and use code with caution.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces a runtime status sidecar mechanism to allow external tools to observe active Gemini CLI sessions. It adds utility functions for writing and reading a runtime.json file containing session metadata—such as PID, session ID, and work directory—and integrates these into the CLI startup process. Feedback was provided regarding the use of synchronous file system operations within the asynchronous writeRuntimeStatus function, which could block the event loop during startup.

Comment thread packages/core/src/utils/runtimeStatus.ts Outdated
@yeelam-gordon

yeelam-gordon commented Apr 28, 2026

Copy link
Copy Markdown
Author

Update: pushed a follow-up commit (eeaf84f) covering two reliability hardening items I'd missed in the initial port:

Concern Status (after follow-up)
Atomic tmp + fsync + rename, cleanup .tmp on failure ✅ matched in initial commit
UnicodeDecodeErrornull (truncated UTF-8) TextDecoder({fatal:true})
Explicit isinstance-style type checks, no coercion typeof + Number.isInteger
Exclude bool from int guards typeof true === 'boolean' is naturally distinct from 'number'; covered by rejects bool pid test
No cleanup-on-quit (consumers verify PID liveness) ✅ followed
Test: no .tmp leftover when underlying write raises ❌ was missing → added in this commit
(defense-in-depth) O_CREAT | O_EXCL on the temp open ❌ was missing → added in this commit ('w''wx')

Local verification: lint clean, typecheck clean, all 19 tests pass.

@yeelam-gordon

yeelam-gordon commented Apr 29, 2026

Copy link
Copy Markdown
Author

Pushed a follow-up commit (e8091fab) that addresses a same-PID cross-session correctness issue.

The bug being fixed

When the same PID switches to serving a different session id mid-flight, the previous session's runtime.json keeps claiming this PID. An external observer running a PID-liveness check would then see one PID mapped to two different sessions and treat both as live — breaking the documented PID → session contract.

Gemini-cli paths that perform an in-process session switch

Path What changes the session id
/clear (/new alias) – clearCommand.ts resetNewSessionState(<freshUUID>)
Session browser resume – useSessionBrowser.ts setSessionId(<resumedId>)

Both paths now do the same four-step dance: capture old session dir → switch id → clearRuntimeStatus(old)writeRuntimeStatus(new, …). All steps are best-effort wrapped in try/catch so observability I/O never blocks user-visible operations.

Verification

  • runtimeStatus.test.ts: 22/22 pass (3 new tests for clearRuntimeStatus).
  • clearCommand.test.ts + useSessionBrowser.test.ts: 11/11 pass (existing tests, no changes needed — the try/catch wrap absorbs the partial mocks).
  • storage.test.ts: 33/33 pass.
  • Lint clean, typecheck clean across both packages.

@yeelam-gordon

yeelam-gordon commented Apr 29, 2026

Copy link
Copy Markdown
Author

Refactored to mirror the cleaner shape from the parallel qwen-code port (PR #3714) — pushed fa05e761.

What changed

The same-PID session swap dance (clearRuntimeStatus(old) + writeRuntimeStatus(new, ...)) used to live at every caller of the session-id-changing methods:

Before After
4-step dance duplicated in clearCommand.ts AND useSessionBrowser.ts Single chokepoint inside Config.setSessionId, gated on a new runtimeStatusEnabled ownership flag

Config now has:

  • A private runtimeStatusEnabled = false field
  • A markRuntimeStatusEnabled() public method, called by gemini.tsx immediately after the first successful sidecar write
  • setSessionId() does the swap dance fire-and-forget when the flag is set AND the session id actually changes; otherwise it's a pure session-id update

clearCommand.ts and useSessionBrowser.ts revert to their original simple shape — no per-callsite runtime-status code remains.

Why the ownership flag

Without the flag, a non-interactive entry point (e.g. gemini --prompt --resume <id>) calling setSessionId could clobber a concurrent shell's runtime.json that shares the outgoing session id. The flag scopes the swap to the process that genuinely owns a sidecar.

Verification (all green)

  • 22/22 runtimeStatus tests pass
  • 33/33 storage tests pass
  • 232/232 config tests pass (including 1 new test that exercises pre-flag no-op, post-flag swap, and idempotent same-id call)
  • 11/11 clearCommand + useSessionBrowser tests pass unchanged
  • Lint and typecheck clean across both packages

Net effect on PR #26149

PR is now 4 commits — the third (e8091fab per-callsite logic) is superseded by the fourth (fa05e761 centralized refactor). The 5-file change is +146 / -62 lines net, but the new shape removes 60+ lines of per-callsite duplication.

@yeelam-gordon

Copy link
Copy Markdown
Author

/azp run

Write a small JSON status file at <session-temp>/runtime.json while a
session is alive that records (pid, sessionId, workDir, hostname,
startedAt, geminiCliVersion). External tools (terminal multiplexers,
tab managers, IDE integrations, observability daemons) can now
reliably map a running PID to its session and answer "is there a
gemini-cli process running against session X?" by reading the sidecar
and verifying the PID is alive.

The on-disk format uses snake_case keys to remain compatible with the
runtime.json format already produced by sibling agentic CLIs (e.g.
kimi-cli), so external observers can share parsing across tools.

Lifecycle:

- Written on session start (clean launch and resume); the resume case
  atomically overwrites the previous PID via tmp-file + fsync + rename.
- NOT cleared on quit or crash. From an external observer's standpoint
  the recorded PID no longer exists in either case, so a liveness check
  is sufficient and explicit cleanup adds nothing on disk.
- Removed naturally when the surrounding session directory is deleted,
  giving a natural bound on accumulation.

The write is best-effort and wrapped in try/catch with debugLogger; a
failure never blocks startup. The hook point is after the
--list-extensions / --list-sessions / --delete-session early exits and
before all three session-serving paths (interactive, ACP,
non-interactive), so utility commands do not leave a sidecar claiming
a session that never started.

Includes 18 unit tests covering field round-trip, atomicity, missing /
malformed / wrong-schema / wrong-types (incl. bool pid, non-integer
pid, array work_dir, top-level array/null payloads), non-ASCII
(Chinese) UTF-8 round-trip with on-disk byte assertion, invalid UTF-8
bytes returning null, atomic overwrite on resume, and CLI version
field population.
Two reliability follow-ups mirroring the kimi-cli PR google-gemini#2082 review:

- writeRuntimeStatus: switch the temp-file open from 'w' to 'wx' so the
  open succeeds only when the path does not already exist. The random
  UUID suffix already makes collision astronomically unlikely; the
  exclusive open (O_CREAT | O_EXCL) adds defense-in-depth against a
  pre-placed regular file or symlink at the temp path that 'w' would
  silently follow and overwrite.

- tests: add a regression test asserting that no .tmp.* file is left
  behind when the underlying rename raises. We force the failure by
  pre-placing a non-empty directory at the target path, which makes
  fs.renameSync fail on every supported platform and exercises the
  catch-block tmp cleanup path.
…witch

Mirrors kimi-cli PR google-gemini#2082 commit 0f79e348. When the same PID switches
to serving a different session id mid-flight, the previous session's
runtime.json must be dropped before the new session's record is written;
otherwise an external observer running a PID-liveness check would see
this PID mapped to BOTH sessions and treat both as live.

Gemini CLI has two such in-process session switches:

- '/clear' (clearCommand) -> resetNewSessionState(<freshUUID>)
- session-browser resume (useSessionBrowser) -> setSessionId(<resumedId>)

Both paths now:

1. Capture the OLD session dir before changing the session id.
2. Switch the session id (resetNewSessionState / setSessionId).
3. clearRuntimeStatus(oldSessionDir) to drop the stale claim.
4. writeRuntimeStatus(newSessionDir, ...) so the PID is observable
   under the new session id from this point on.

All four steps are best-effort; the runtime-status I/O is wrapped in
try/catch so a write failure on slow/network/read-only filesystems
never blocks the user-visible operation.

Adds clearRuntimeStatus() to the runtime-status module: synchronous,
swallows ENOENT/ENOTDIR (idempotent), all other I/O errors silenced.
Three new tests: removes existing file, idempotent on missing file,
no-op on missing dir.
…onId

Mirror the cleaner shape from the parallel qwen-code port (PR google-gemini#3714).
Move the runtime.json clear+write dance out of the two callers and
into Config.setSessionId itself, gated on a new ownership flag that
the interactive UI bootstrap flips after the first successful write.

Why
---

Previous shape (3 commits ago) put the four-step swap dance at every
caller of resetNewSessionState / setSessionId:

  1. capture old session dir
  2. switch session id
  3. clearRuntimeStatus(old)
  4. writeRuntimeStatus(new, ...)

That pattern is correct but fragile: a future caller of setSessionId
that forgets the dance will silently leave a stale runtime.json
claiming this PID for the previous session id.

Centralized shape
-----------------

- Config gains a private runtimeStatusEnabled flag (default false) and
  a public markRuntimeStatusEnabled() method.
- Config.setSessionId now captures the outgoing session dir before
  storage updates, then — only when the flag is set AND the session id
  actually changed — drops the previous session's sidecar and writes a
  fresh one for the incoming session as fire-and-forget best-effort.
- gemini.tsx flips the flag immediately after the initial sidecar
  write succeeds, so all subsequent in-process session swaps are
  covered by the centralized dance.
- clearCommand and useSessionBrowser revert to their original shape;
  no per-callsite runtime-status code remains.

Why the ownership flag
----------------------

Without it, a non-interactive entry point (e.g. gemini --prompt
--resume <id>) that calls setSessionId — or a future code path that
does — would clobber a concurrent shell's runtime.json sharing the
outgoing session id. The flag scopes the swap-time refresh to the
process that genuinely owns a sidecar, mirroring qwen-code's fix
(commit e1e8d29b) and the spirit of kimi-cli's later-reverted
e237951f shell-mode-only guard.

Tests
-----

Adds one config.test.ts case verifying:

- Before markRuntimeStatusEnabled, setSessionId leaves a pre-placed
  runtime.json untouched.
- After markRuntimeStatusEnabled, a same-PID swap clears the outgoing
  sidecar and writes the incoming one (vi.waitFor for the fire-and-
  forget write).
- Setting the SAME session id twice is a no-op (idempotent: no clear,
  no rewrite).

All 22 runtime-status tests, 33 storage tests, 232 config tests, 2
clearCommand tests, and 9 useSessionBrowser tests pass.
Address gemini-code-assist review feedback: writeRuntimeStatus is
declared async but used synchronous fs APIs (mkdirSync, openSync,
writeSync, fsyncSync, closeSync, renameSync, unlinkSync). On the
session-startup hot path, those calls block the Node.js event loop
and contradict the function's async signature.

Switch the entire write pipeline to fs.promises.* equivalents:

- fs.promises.mkdir({recursive:true})
- fs.promises.open(tempPath, 'wx', 0o600) -> FileHandle
- handle.write / handle.sync / handle.close
- fs.promises.rename
- fs.promises.unlink for temp cleanup on failure

The 'wx' flag (O_CREAT | O_EXCL) and the fsync-before-rename
guarantee carry over unchanged. The catch block now awaits both
handle.close (if still open) and the temp unlink so failed writes
leave no leftover.

Tests unchanged: all 22 runtimeStatus.test.ts cases pass against the
async implementation, including the rename-failure leftover-cleanup
case (engineered via a pre-placed non-empty directory at the target).
@gemini-cli

gemini-cli Bot commented May 6, 2026

Copy link
Copy Markdown
Contributor

Hi there! Thank you for your interest in contributing to Gemini CLI.

To ensure we maintain high code quality and focus on our prioritized roadmap, we only guarantee review and consideration of pull requests for issues that are explicitly labeled as 'help wanted'.

This PR will be closed in 7 days if it remains without that designation. We encourage you to find and contribute to existing 'help wanted' issues in our backlog! Thank you for your understanding.

@gemini-cli

gemini-cli Bot commented May 13, 2026

Copy link
Copy Markdown
Contributor

This pull request is being closed as it has been open for 14 days without a 'help wanted' designation. We encourage you to find and contribute to existing 'help wanted' issues in our backlog! Thank you for your understanding.

@gemini-cli gemini-cli Bot closed this May 13, 2026
@sripasg sripasg added the size/l A large sized PR label Jun 2, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size/l A large sized PR status/need-issue Pull requests that need to have an associated issue. status/pr-nudge-sent

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants