Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ node_modules/
.pnpm-store/
package-lock.json
*.egg-info/
__pycache__/

# Build outputs
dist/
Expand Down
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,24 @@ All notable changes to Pane will be documented in this file.
## [Unreleased]

### Added
- Opt-in cadence flags for `runpane watch --follow`, so an orchestrator that pays for every line can wake up less often without changing what everyone else sees. `--settle <ms>` emits READY only after a pane stays idle that long (a BUSY inside the window cancels it silently, which removes the idle/working flips a pane makes while it waits on subagents). `--blocked-settle <ms>` does the same for BLOCKED. `--min-interval <ms>` holds non-urgent lines and flushes them together at most once per interval; BLOCKED bypasses it. `--idle-backoff` fires IDLE at `--idle-after`, then 30m, 1h, 3h, then daily, and resets on activity. Use `--kinds` to drop `agent.busy`, which carries no action. The flags require `--follow`. Recommended orchestrator invocation, budgeted at about 6 wake-ups per active pane per hour worst case, usually 1 to 3:

```bash
runpane watch --self-test
runpane watch --follow --kinds agent.ready,agent.blocked,agent.idle,panel.exited,pane.gone --settle 180000 --blocked-settle 30000 --min-interval 600000 --idle-backoff
```

Pane Chat arms this automatically through its pane-orchestrator skill, so you only need the flags for your own scripts. `runpane watch --help`, `runpane agent-context --command watch --json`, and `runpane doctor` (which now prints the effective watch defaults) document every flag with its default.
- Cursor Agent CLI (`cursor-agent`) as a third built-in agent tool: launch pill/menu entries with `mod+alt+5`, prompt-as-argument delivery, chat pre-creation with resume-after-restart, at-a-glance status detection, RunPane `--agent cursor` support with a doctor fallback probe for `~/.local/bin`, and a Cursor option for the Pane Chat orchestrator. Pane supports Cursor in macOS, Linux, and WSL repositories. Native Windows launches stay disabled.

### Changed
- Custom-command keyboard shortcuts moved from `mod+alt+5..9` to `mod+alt+6..9` to make room for the Cursor slot.
- Cursor Agent is now available inside WSL repositories.
- `runpane watch` defaults are unchanged and stay responsive: no settle, no batching, all event kinds, IDLE every 10 minutes, HEARTBEAT every 60 seconds under `--follow`.
- The Pane Chat orchestrator's Liveness Contract now arms the cadence flags above, filters HEARTBEAT out of its monitor, and judges a dead watch by a non-zero exit or a `WATCH ERROR` line rather than by silence.

### Fixed
- `runpane watch` no longer reports STUCK for the grey prompt suggestion Claude Code shows in an empty composer. STUCK now means real unsubmitted composer text.

## [1.1.123] - 2026-04-25

Expand Down
275 changes: 249 additions & 26 deletions contracts/runpane/contract.json

Large diffs are not rendered by default.

8 changes: 8 additions & 0 deletions contracts/runpane/schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -125,13 +125,21 @@
"format",
"heartbeatSeconds",
"idleAfterMs",
"settleMs",
"blockedSettleMs",
"minIntervalMs",
"idleBackoff",
"agentsOnly",
"includeHeldInputPresence"
],
"properties": {
"format": { "enum": ["lines", "json"] },
"heartbeatSeconds": { "type": "number" },
"idleAfterMs": { "type": "number" },
"settleMs": { "type": "number" },
"blockedSettleMs": { "type": "number" },
"minIntervalMs": { "type": "number" },
"idleBackoff": { "type": "boolean" },
"agentsOnly": { "type": "boolean" },
"includeHeldInputPresence": { "type": "boolean" }
},
Expand Down
10 changes: 10 additions & 0 deletions docs/RUNPANE_CLI_CONTRACT.md
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,9 @@ runpane panes rename --pane <pane-id> --name issue-393 --yes --json
runpane panels list --pane <pane-id> --json
runpane panels output --panel <panel-id> --limit 200 --json
printf 'Continue\n' | runpane panels input --panel <panel-id> --input-file - --yes --json
runpane watch --self-test
runpane watch --follow
runpane watch --follow --kinds agent.ready,agent.blocked,agent.idle,panel.exited,pane.gone --settle 180000 --blocked-settle 30000 --min-interval 600000 --idle-backoff
runpane help
runpane <command> --help
```
Expand Down Expand Up @@ -167,6 +170,8 @@ If composer submission cannot be verified without risking a duplicate, the creat

When running from WSL while Pane is installed on Windows, the Linux wrapper may look for a missing `/tmp/pane-daemon.../daemon.sock` or resolve to a Windows shim such as Volta. In that case invoke the Windows wrapper through PowerShell from a Windows cwd, for example `powershell.exe -NoProfile -Command 'Set-Location $env:TEMP; runpane repos list --json'`.

`runpane watch` waits for workspace transitions from the daemon journal without polling. `--follow` keeps waiting and prints one line per event: READY, BLOCKED, IDLE, STUCK, NEW, GONE, EXIT, plus HEARTBEAT every 60 seconds as proof of life. Defaults are responsive: no settle, no batching, all kinds, IDLE every `--idle-after`. Expensive consumers opt into `--kinds` (drop `agent.busy`; BUSY carries no action), `--settle <ms>` (READY only after a quiet window; a BUSY inside it cancels the line), `--blocked-settle <ms>`, `--min-interval <ms>` (batch non-urgent lines; BLOCKED bypasses it), and `--idle-backoff` (10m, 30m, 1h, 3h, then daily). The recommended orchestrator invocation is `runpane watch --follow --kinds agent.ready,agent.blocked,agent.idle,panel.exited,pane.gone --settle 180000 --blocked-settle 30000 --min-interval 600000 --idle-backoff`, which budgets about 6 wake-ups per active pane per hour worst case, usually 1-3. Pane Chat arms it automatically through its skill; only your own scripts need the flags. STUCK means real unsubmitted composer text, never an agent prompt suggestion. Judge a dead watch by a non-zero exit or a WATCH ERROR line, not by silence.

## Agent Context

Pane lets a developer manage saved base repositories, user-visible Panes (Pane sessions) for feature/PR work, and terminal-backed panel tabs. A Pane is a visible workspace that normally maps to one Pane-managed git worktree and branch; a panel is a terminal tab inside one Pane and shares that Pane's worktree; an agent is a CLI process running inside a panel.
Expand Down Expand Up @@ -212,6 +217,7 @@ Brief tools:
- `panels submit`: Send text plus terminal Enter to a terminal panel.
- `panels submit-composer`: Submit an agent composer with the correct key sequence, including Ctrl+Enter for Codex.
- `panels wait`: Wait for terminal initialized, ready, idle, or text state with compact output.
- `watch`: Wait for workspace transitions (READY, BLOCKED, IDLE, STUCK, NEW, GONE, EXIT) from the daemon journal without polling; responsive by default, with opt-in cadence flags for expensive consumers.

Managed AGENTS.md block body:

Expand Down Expand Up @@ -324,6 +330,9 @@ These flags are consumed by local daemon-control commands:
--format <lines|json>
--heartbeat <seconds>
--idle-after <milliseconds>
--settle <milliseconds>
--blocked-settle <milliseconds>
--min-interval <milliseconds>
--body-file <path|->
--json
--wait-ready
Expand All @@ -341,6 +350,7 @@ These flags are consumed by local daemon-control commands:
--include-shells
--no-held-input
--self-test
--idle-backoff
--report
```

Expand Down
135 changes: 135 additions & 0 deletions main/src/ipc/runpane.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -699,6 +699,141 @@ describe('runpane IPC handlers', () => {
expect(truncated.entries).toContainEqual(expect.objectContaining({ kind: 'agent.idle', idleCount: 1 }));
});

describe('workspace wait cadence', () => {
const readyEntry = {
kind: 'agent.ready' as const,
paneId: session.id,
paneName: session.name,
panelId: terminalPanel.id,
agentType: 'codex',
source: 'agent' as const,
from: 'working' as const,
to: 'idle' as const,
};
const busyEntry = { ...readyEntry, kind: 'agent.busy' as const, from: 'idle' as const, to: 'working' as const };
const cadenceRequest = { as: 'cadence', timeoutMs: 0, idleAfterMs: 0, kinds: ['agent.ready'], settleMs: 60_000 };

function cadenceRegistry(options: { capacity?: number } = {}) {
vi.useFakeTimers();
vi.setSystemTime(new Date('2026-01-01T12:00:00.000Z'));
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'pane-runpane-cadence-test-'));
tempDirs.push(directory);
const workspaceJournal = new WorkspaceJournal(options);
const workspaceCursorStore = new WorkspaceCursorStore(path.join(directory, 'workspace-cursors.json'));
const registry = createRegistry(createServices({ workspaceJournal, workspaceCursorStore }));
return { workspaceJournal, workspaceCursorStore, registry };
}

it('keeps a pending READY across a timed-out request and delivers it once settled', async () => {
const { workspaceJournal, registry } = cadenceRegistry();
await registry.invoke('runpane:workspace:wait', [cadenceRequest]);
workspaceJournal.append(readyEntry);

const held = await registry.invoke('runpane:workspace:wait', [cadenceRequest]);
expect(held).toMatchObject({ entries: [], timedOut: true });

vi.setSystemTime(new Date('2026-01-01T12:01:01.000Z'));
const settled = await registry.invoke('runpane:workspace:wait', [cadenceRequest]);
expect(settled.entries).toEqual([expect.objectContaining({ kind: 'agent.ready', gen: 1, settledMs: 61_000 })]);
expect(await registry.invoke('runpane:workspace:wait', [cadenceRequest])).toMatchObject({ entries: [] });
});

it('resumes a reused instance from its read cursor so held entries are delivered exactly once', async () => {
const { workspaceJournal, workspaceCursorStore, registry } = cadenceRegistry();
await registry.invoke('runpane:workspace:wait', [cadenceRequest]);
workspaceJournal.append(readyEntry);
vi.setSystemTime(new Date('2026-01-01T12:00:30.000Z'));
workspaceJournal.append({ ...readyEntry, panelId: 'panel-other' });
expect((await registry.invoke('runpane:workspace:wait', [cadenceRequest])).entries).toEqual([]);
const cursor = workspaceCursorStore.get('cadence');
expect(cursor?.pendingGen ?? cursor?.gen).toBe(0);

vi.setSystemTime(new Date('2026-01-01T12:01:01.000Z'));
const first = await registry.invoke('runpane:workspace:wait', [cadenceRequest]);
expect(first.entries.map((entry: { gen: number }) => entry.gen)).toEqual([1]);
expect(first.generation).toBe(2);
vi.setSystemTime(new Date('2026-01-01T12:01:31.000Z'));
const second = await registry.invoke('runpane:workspace:wait', [cadenceRequest]);
expect(second.entries.map((entry: { gen: number }) => entry.gen)).toEqual([2]);
expect((await registry.invoke('runpane:workspace:wait', [cadenceRequest])).entries).toEqual([]);
});

it('drains every page before flushing so a later BUSY still cancels an older READY', async () => {
const { workspaceJournal, registry } = cadenceRegistry();
await registry.invoke('runpane:workspace:wait', [cadenceRequest]);
workspaceJournal.append(readyEntry);
vi.setSystemTime(new Date('2026-01-01T12:00:01.000Z'));
workspaceJournal.append(busyEntry);

vi.setSystemTime(new Date('2026-01-01T12:02:00.000Z'));
const result = await registry.invoke('runpane:workspace:wait', [{ ...cadenceRequest, limit: 1 }]);
expect(result).toMatchObject({ entries: [], generation: 2 });
vi.setSystemTime(new Date('2026-01-01T12:05:00.000Z'));
expect(await registry.invoke('runpane:workspace:wait', [{ ...cadenceRequest, limit: 1 }])).toMatchObject({ entries: [] });
});

it('never moves the durable cursor past a held READY', async () => {
const { workspaceJournal, workspaceCursorStore, registry } = cadenceRegistry();
await registry.invoke('runpane:workspace:wait', [cadenceRequest]);
workspaceJournal.append({ ...readyEntry, panelId: 'panel-other', paneId: 'session-other', paneName: 'other' });
workspaceJournal.append(readyEntry);
workspaceJournal.append({ kind: 'pane.created', paneId: 'session-new', paneName: 'new', source: 'session' });

const held = await registry.invoke('runpane:workspace:wait', [cadenceRequest]);
expect(held.entries).toEqual([]);
const cursor = workspaceCursorStore.get('cadence');
expect(cursor?.pendingGen ?? cursor?.gen).toBe(0);

const raw = await registry.invoke('runpane:workspace:wait', [{ as: 'cadence', timeoutMs: 0, idleAfterMs: 0, kinds: ['agent.ready'] }]);
expect(raw.entries.map((entry: { gen: number }) => entry.gen)).toEqual([1, 2]);
});

it('discards held entries when the consumer changes its filter', async () => {
const { workspaceJournal, registry } = cadenceRegistry();
const scoped = { ...cadenceRequest, paneIds: [session.id] };
await registry.invoke('runpane:workspace:wait', [scoped]);
workspaceJournal.append(readyEntry);
expect((await registry.invoke('runpane:workspace:wait', [scoped])).entries).toEqual([]);

vi.setSystemTime(new Date('2026-01-01T12:02:00.000Z'));
const rescoped = await registry.invoke('runpane:workspace:wait', [{ ...cadenceRequest, paneIds: ['session-other'] }]);
expect(rescoped.entries).toEqual([]);
vi.setSystemTime(new Date('2026-01-01T12:04:00.000Z'));
expect((await registry.invoke('runpane:workspace:wait', [{ ...cadenceRequest, paneIds: ['session-other'] }])).entries).toEqual([]);
});

it('discards held entries when the consumer changes its idle schedule', async () => {
const { workspaceJournal, registry } = cadenceRegistry();
await registry.invoke('runpane:workspace:wait', [cadenceRequest]);
workspaceJournal.append(readyEntry);
expect((await registry.invoke('runpane:workspace:wait', [cadenceRequest])).entries).toEqual([]);

vi.setSystemTime(new Date('2026-01-01T12:02:00.000Z'));
const rescheduled = { ...cadenceRequest, idleBackoff: true };
// The rebuilt cadence re-reads the held READY from the capped cursor and settles it afresh.
const first = await registry.invoke('runpane:workspace:wait', [rescheduled]);
expect(first.entries.map((entry: { kind: string; gen: number }) => [entry.kind, entry.gen])).toEqual([['agent.ready', 1]]);
});

it('discards the cadence on a cursor-truncated reset', async () => {
const { workspaceJournal, registry } = cadenceRegistry({ capacity: 2 });
await registry.invoke('runpane:workspace:wait', [cadenceRequest]);
workspaceJournal.append(readyEntry);
expect((await registry.invoke('runpane:workspace:wait', [cadenceRequest])).entries).toEqual([]);

for (const paneId of ['one', 'two', 'three']) {
workspaceJournal.append({ kind: 'pane.created', paneId, paneName: paneId, source: 'session' });
}
const truncated = await registry.invoke('runpane:workspace:wait', [cadenceRequest]);
expect(truncated).toMatchObject({ reset: { reason: 'cursor-truncated' } });

vi.setSystemTime(new Date('2026-01-01T12:02:00.000Z'));
const after = await registry.invoke('runpane:workspace:wait', [cadenceRequest]);
expect(after.entries).toEqual([]);
expect(after.reset).toBeUndefined();
});
});

it('announces an evicted named workspace cursor as unknown', async () => {
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'pane-runpane-cursor-test-'));
tempDirs.push(directory);
Expand Down
Loading
Loading