Skip to content

Session command line: start, extend and end the running app's session from a script - #99

Open
0xRafie wants to merge 8 commits into
krishhgg:mainfrom
0xRafie:feature/cli-sessions
Open

0xRafie wants to merge 8 commits into
krishhgg:mainfrom
0xRafie:feature/cli-sessions

Conversation

@0xRafie

@0xRafie 0xRafie commented Oct 12, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Adds Insomnia --session start <seconds> | extend <seconds> | end | status, a one-shot mode that drives the running menu bar app's session from a script or an agent. The session it starts is the one the menu bar counts down. It ends from the menu like any other and keeps the battery and heat rules, and the command line never runs pmset itself.

The use case: an agent that keeps working with the lid closed can start or extend a session and end it when its process exits, while the person sees the same countdown in the menu bar. Related to #57; this is not Auto, which #79 implements.

How it works

  • Each command line writes its own request file, session-command.<id>.request, holding start <seconds>, extend <seconds> or end. It writes the file under a private name and renames it into place. No two command lines share a path, so publishing, the app's claim and a take-back are each one rename(2) of that caller's own file.
  • SessionCommandWatcher watches the support directory, as LidSimulation does, from the end of the launch reconcile. It claims each request by rename(2) and drops one that is older than 5 s or malformed. It runs the rest oldest first through SessionManager.run, which calls the same start, extend and end the menu uses, and answers in session-command.<id>.reply as JSON.
  • tryStart and tryExtend return whether this call took effect (start and extend are unchanged wrappers), so a start queued behind another one answers "already running" instead of a false success. An extend that finds no session yet waits for queued work first.
  • An end is always a full end(reason: .user), whether or not the manager holds a session. It abandons a start in flight, finishes an earlier end or a dirty journal, and ends a session.json that a delayed launch recovery has not adopted. Every EndOutcome other than .restored becomes a reason and exit 1.
  • The command line takes its own unclaimed request back after 2 s and tells "not running", "older build" and "still starting" apart. It waits up to 60 s for the answer, prints one line, and exits 0, 1, or 64 on a usage error. status only reads session.json.
  • Info.plist declares InsomniaSessionCommandVersion 1. A build without this mode opens the menu bar app instead, so a script that may meet one checks the key first, the same idea as InsomniaResumeFrozenVersion.

Trust boundary

The command file is the same boundary as config.json and the simulate-lid trigger: only this user can write the support directory, and anyone who can write it already controls the app. Any process of this user can also run the four sudoers commands directly; going through the app adds the journal, the backstop and the floors.

Testing

  • New SessionCommandTests:
    • the request format and file names;
    • the manager running each command, including a start queued behind one in flight, an end during an in-flight start, an end that finds an unadopted session.json, and an end whose restore fails;
    • the watcher: oldest first, stale, malformed, and written before the watch;
    • the command line: answered, refused, not running, an older or still-starting app, no answer, another command line's request left alone, rounding, status, usage, and the Info.plist key.
  • This Mac has the Command Line Tools only, without XCTest. I ran SessionCommandTests (24 tests) through a local XCTest stand-in, and checked each guard by removing it to confirm a test fails. CI runs the whole suite.
  • swift build -c release -Xswiftc -warnings-as-errors passes. It was built with the macOS 26.5 SDK, because the Command Line Tools' macOS 27 SDK lacks the SwiftUI macro plugin.
  • On a real Mac (MacBook Air M2, macOS 26.7.1), installed with scripts/install.sh:
    • --session start 120 shows the countdown in the menu bar;
    • extend, a refused second start, status, end and hold-to-end each behave as described;
    • with the 0.1.0 app still running, the command line names it as an older build and leaves no file behind.
  • docs/releasing.md has a new real-Mac check, and README and docs/spec.md describe the mode.

RetriggerView in GreptileConfidence Score: 5/5

The PR appears safe to merge; no new actionable issues were found.

Summary

Adds Insomnia --session so scripts can control the running app’s session.

  • Scripts can read the countdown and send timed-session commands.
  • The running app handles script requests through its session controls.

Both earlier findings are addressed: end always runs full cleanup, and callers no longer share a request path.

Diagram

sequenceDiagram
  participant CLI as Command line
  participant Files as Support directory
  participant App as Running app
  participant Manager as SessionManager
  CLI->>Files: Publish own request by rename
  App->>Files: Claim request by rename
  App->>Manager: Run start, extend, or end
  Manager-->>App: Return outcome
  App->>Files: Write reply atomically
  CLI->>Files: Read and remove reply
  CLI-->>CLI: Print result and exit
Loading

Reviews (3) · Last reviewed commit: "Session: refuse a start queued behind an..." · Reviewed by Greptile

Insomnia --session start <seconds> | extend <seconds> | end | status lets a
script or an agent start, extend and end the session the menu bar counts
down, with the same battery and heat rules. The command line changes
nothing itself: it links one request into session-command in the support
directory, the app's watcher claims it, runs it like the matching menu
action and answers in session-command.reply.<id>. A request nothing claims
within 2 s is taken back, and one older than 5 s is dropped, so a request
left behind never starts a session later.
A start queued behind another, an end whose restore failed, and an end that
arrived while a start was in flight could all answer done. The manager's
tryStart and tryExtend now say whether this call took effect, an end maps
every EndOutcome other than restored to a reason, and an end or extend with
no session yet waits for queued work first. The watcher starts after the
launch reconcile, a request a killed command line left no longer blocks the
next one, a command line waits on and takes back only its own file (by
inode), old answers are removed, and an app still starting is told apart
from an older build.
…nd every time

Greptile on krishhgg#99: an end that found no session answered done without ending,
which left a dirty journal or an unadopted session.json behind, and the
take-back of a shared request path could still drop another caller's
request when three command lines raced. An end now always runs end(reason:
.user), which also abandons a start in flight. Each command line writes its
own session-command.<id>.request, so publishing, the app's claim and a
take-back are each one rename of that caller's own file, and the app serves
the requests it finds oldest first.
@krishhgg

Copy link
Copy Markdown
Owner

End-to-end test in a VM on macOS 27.0.1

Every command did what the PR says, from a shell in the VM. start, extend, end and status reached the running app and changed the session, and the menu bar showed the new time. With the app closed, start said so and exited 1. No request or reply files were left in the support folder.

Setup

A Tart VM on the maintainer's Mac mini, macOS 27.0.1 (26A434) with Xcode 27.0. Each version was built from source and installed with scripts/install.sh, then driven by a script inside the VM using Accessibility and posted mouse events.

This PR's head (34f3f43). The script (t99.sh) ran Insomnia.app/Contents/MacOS/Insomnia --session ... first with the app closed, then with it running.

Results

Command Exit Output
status (app closed) 0 no session is running
start 120 (app closed) 1 Insomnia is not running, so nothing took the command; open the app and try again
status (app running) 0 no session is running
start 120 0 running until 00:55:02Z (2m left); SleepDisabled 1, session.json written
start 60 1 a session is already running; use extend
extend 300 0 running until 01:00:02Z (7m left)
status 0 running until 01:00:02Z (7m left)
end 0 no session is running; log: session end (user), sleep restored, session command end (...): done
end again 0 no session is running
frobnicate 64 usage: Insomnia --session start | extend | end | status

After end: no session.json, SleepDisabled 0, and 0 request or reply files left.

The menu bar after start 120, then after extend 300:

started

extended

Not covered

  • Two commands sent at the same moment, or a command sent while the app is starting a session.
  • macOS 26 and real hardware.

The full log is run-full-lab27.log.

🤖 Generated with Claude Code

@krishhgg

Copy link
Copy Markdown
Owner

Thanks for this. Two things before it can merge.

1. Merge main

The branch now conflicts with main in docs/releasing.md, after #98 merged. Please merge current main into this branch with a merge commit (no rebase or force-push), resolve the conflict, and push.

2. A start can be refused before an earlier end runs

Start with a session running, then send end followed by start. The app runs each request in its own task (InsomniaApp.swift:89). While the end waits on the lifecycle queue or the recovery lock, the early if session != nil check in SessionCommand.swift:87 still sees the old session. It refuses the start as already running. The end then runs, and the Mac is left with no session, though the docs say requests run oldest first.

Fix: remove the early check and let performStart decide inside the lifecycle queue. Keep the check after tryStart for the refusal message. Add a test that holds the queue, sends end and then start, and checks that a new session starts.

Push these as new commits on this branch. CI, Greptile and the Codex review then run again on the new head. My VM test above covered 34f3f43, so I'll rerun it on the new head.

🤖 Generated with Claude Code

…st runs first

`end` then `start` each run in their own task. While the end waited on the
lifecycle queue or the recovery lock, the early `session != nil` check in
`run(.start)` still saw the old session and refused the start as already
running; the end then ran and left no session. The check is gone, so the
start queues behind the end and `performStart` decides under the lock. The
check after `tryStart` still gives the refusal message.

The new test holds an extend at the backstop arm, queues `end` and then
`start`, and checks that the start makes a new session.
`tryStart` refuses a start while an end is pending, but only when the
request arrives. An end already in the queue can leave itself to its retry
(`scheduleEndRetry` in `performEnd`), and a start queued behind it went on
to start a session that the retry stopped 30 s later. `performStart` now
checks `pendingEnd` as well, with the same refusal.

Removing the early check in `run(.start)` made this reachable from `end`
then `start`; a start sent with no session running could already reach it.
The new test fails the end's arm, lets the start's succeed, and checks the
start is refused and nothing runs.
@0xRafie

0xRafie commented Oct 21, 2026

Copy link
Copy Markdown
Contributor Author

Both done, plus one fix that removing the early check made reachable.

  1. Merged current main with a merge commit (6fb9dbe). In docs/releasing.md I kept this branch's --session line and main's rewritten sleep guard line.
  2. bad9717: run(.start) no longer checks session != nil before queueing. performStart decides, and the check after tryStart still gives the refusal message. testAStartSentAfterAQueuedEndStartsANewSession holds an extend at the backstop arm, queues end then start, and checks that the start makes a new session. It fails on 34f3f43.
  3. e738536: a start queued behind an end that is left to its retry (scheduleEndRetry in performEnd) went on to start a session that the retry stopped 30 s later. performStart now refuses while pendingEnd is set, with the message tryStart already gives. testAStartQueuedBehindAnEndLeftToRetryIsRefused covers it.

@krishhgg

Copy link
Copy Markdown
Owner

Thanks for this, @0xRafie. To avoid merge conflicts with the other open pull requests, your branch lands through #146, which merges your head commit e738536 unchanged. When #146 merges, GitHub marks this pull request merged and the commits stay yours.

Please don't push to this branch now. A new commit wouldn't be in #146, so this pull request would stay open instead of showing as merged. If something needs changing, comment here or on #146 and it'll go in as a follow-up there.

#146 adds one maintainer commit after your merge: tryExtend now reports success only when the end actually moved later. At the 30 day cap, or after a lower maxDuration, --session extend returned success for an extension that never happened; it now prints your "not extended" reason.

🤖 Generated with Claude Code

krishhgg added a commit that referenced this pull request Oct 27, 2026
Lands #99 at e738536, by 0xRafie.

Conflict with #137 and the maintainer's time change fix, resolved here:
each added an app-lifetime object to InsomniaApp, the display sleep and
time change observers and #99's session command watcher. All three are
kept. The observers start first; the watcher starts after the launch
reconcile, as in #99.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants