Skip to content
Open
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 README.md
Original file line number Diff line number Diff line change
Expand Up @@ -261,6 +261,7 @@ made every install think a phantom `0.6.0` was available.
| [docs/VERIFICATION.md](docs/VERIFICATION.md) | The screenshot loop, and **what is not verified** |
| [DECISIONS.md](DECISIONS.md) | Every deviation from the brief, the issue, or openpets — with reasons |
| [docs/ASSETS.md](docs/ASSETS.md) | Swapping the art with no code changes |
| [docs/CLAUDE-CODE.md](docs/CLAUDE-CODE.md) | The Claude Code status crown — what it does, what you need, how to wire it up |
| [docs/PROMPT.md](docs/PROMPT.md) | The implementation brief this was built from, errors corrected in place |
| [SECURITY.md](SECURITY.md) | Reporting a vulnerability |
| [THIRD-PARTY-NOTICES.md](THIRD-PARTY-NOTICES.md) | openpets (MIT), Noto Color Emoji (OFL 1.1 / Apache-2.0) |
Expand Down
131 changes: 131 additions & 0 deletions apps/desktop/src/claude/claude-state-source.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
/**
* Claude Code's state, read off a file that Claude Code's hooks write.
*
* The transport is a file holding one word, because a hook that is `echo running > file` cannot
* fail in an interesting way: no JSON to parse, no port to bind, no dependency on either side.
*
* No Electron import. This module is the one place that knows how the state arrives, so it is
* also the only file that changes when multi-session support lands — at which point it reads a
* directory of per-session files and aggregates them instead.
*/

import { mkdirSync, readFileSync, statSync, watch, type FSWatcher } from 'node:fs'
import { homedir } from 'node:os'
import { dirname, join } from 'node:path'
import { CLAUDE_STATES, type ClaudeState } from '../pet-frame.js'

/**
* How old a state file may be before it is disbelieved.
*
* A session killed with Ctrl+C may never fire its `SessionEnd` hook, leaving a file that would
* otherwise pin the cap on for the rest of the day. The mtime is the guard, which is why the
* file holds only a word: the timestamp already exists in the filesystem, and putting a second
* one in the payload would be a second source of truth for the same fact.
*/
export const CLAUDE_STATE_STALE_MS = 15 * 60_000

/** How often to re-read regardless of the watch. See `start()`. */
const DEFAULT_POLL_MS = 5_000

export function defaultClaudeStateFile(): string {
return process.env.ARGOS_CLAUDE_STATE_FILE ?? join(homedir(), '.argos', 'claude-state')
}

export interface ClaudeStateSourceOptions {
/** Defaults to `defaultClaudeStateFile()`. */
file?: string
/** Injected so tests can drive staleness without waiting a quarter of an hour. */
now?: () => number
pollMs?: number
onChange?: (state: ClaudeState) => void
log?: (message: string, meta?: unknown) => void
}

export interface ClaudeStateSource {
start(): void
stop(): void
/** The last state read. Cheap: no disk access. */
current(): ClaudeState
/** Re-read now and report a change if there is one. Returns the new state. */
refresh(): ClaudeState
}

const KNOWN = new Set<string>(CLAUDE_STATES)

function readState(file: string, nowMs: number): ClaudeState {
let raw: string
let mtimeMs: number
try {
mtimeMs = statSync(file).mtimeMs
raw = readFileSync(file, 'utf8')
} catch {
// Missing, unreadable, a directory, a permission error — all the same answer.
return 'none'
}
if (nowMs - mtimeMs > CLAUDE_STATE_STALE_MS) return 'none'
const word = raw.trim().toLowerCase()
return KNOWN.has(word) ? (word as ClaudeState) : 'none'
}

export function createClaudeStateSource(
options: ClaudeStateSourceOptions = {},
): ClaudeStateSource {
const file = options.file ?? defaultClaudeStateFile()
const now = options.now ?? Date.now
const pollMs = options.pollMs ?? DEFAULT_POLL_MS
const onChange = options.onChange ?? ((): void => {})
const log = options.log ?? ((): void => {})

let state: ClaudeState = 'none'
let watcher: FSWatcher | null = null
let timer: NodeJS.Timeout | null = null

const refresh = (): ClaudeState => {
const next = readState(file, now())
if (next !== state) {
state = next
onChange(state)
}
return state
}

return {
current: () => state,
refresh,

start(): void {
// Make the directory ourselves so the watch has a target from the first launch, rather
// than silently doing nothing until the first hook happens to create it.
try {
mkdirSync(dirname(file), { recursive: true })
} catch (error) {
log('claude-state: could not create the state directory', error)
}

refresh()

// Watch the *directory*, not the file: a writer that replaces the file swaps the inode,
// and a file-level watch is then pointed at something nothing will ever touch again.
try {
watcher = watch(dirname(file), { persistent: false }, () => {
refresh()
})
} catch (error) {
log('claude-state: directory watch unavailable, polling only', error)
}

// Backstop. fs.watch misses events on some platforms and filesystems, and a missed event
// here means a cap stuck on the wrong colour — the one failure the whole feature is
// supposed to prevent.
timer = setInterval(refresh, pollMs)
timer.unref?.()
},

stop(): void {
watcher?.close()
watcher = null
if (timer) clearInterval(timer)
timer = null
},
}
}
15 changes: 15 additions & 0 deletions apps/desktop/src/main/app-shell.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import { SettingsStore } from './settings-store.js'
import { createTray, type TrayController } from './tray.js'
import { createPetWindow, type PetWindow } from './pet-window.js'
import { createPetController, type PetController } from './pet-controller.js'
import { createClaudeStateSource } from '../claude/claude-state-source.js'
import { createMenuController, type MenuController } from './menu.js'
import { createActions } from './actions.js'
import type { MenuViewModel, UpdateState } from './menu-template.js'
Expand Down Expand Up @@ -350,10 +351,22 @@ export async function startApp(): Promise<AppShell> {
},
})

// Claude Code's state, for the cap. `tickNow` rather than waiting for the next tick: a colour
// that lags the terminal by a tick is not worth having, and `tickNow` is the existing seam for
// exactly this — it runs the tick body out of phase without resetting the interval.
const claudeState = createClaudeStateSource({
log,
onChange() {
controller?.tickNow()
},
})
claudeState.start()

controller = createPetController({
pet,
displays,
getMovementEnabled: () => settings.get().movementEnabled,
getClaudeState: () => claudeState.current(),
onPositionChanged(displayKey, petCentreX, feetY) {
settings.patch({ position: { displayKey, x: petCentreX, feetY } })
},
Expand Down Expand Up @@ -915,6 +928,7 @@ export async function startApp(): Promise<AppShell> {
backdrop: () => backdrop,
spriteRect: () => pet.spriteRect(),
bubbleBand: () => pet.bubbleBand(),
crownVisible: () => pet.crownVisible(),
setMovement: (enabled) => {
// The same three steps `toggleMovement` takes — durable setting, immediate trigger, out-of-phase
// tick — so freezing the pet for a screenshot goes through the real path rather than a back door
Expand Down Expand Up @@ -1016,6 +1030,7 @@ export async function startApp(): Promise<AppShell> {
callouts.dispose()
toasts.destroyAll()
controller?.stop()
claudeState.stop()
// Flush before tearing anything down — an unflushed position or reminder deadline is
// exactly the state that must survive a quit.
await settings.flush()
Expand Down
6 changes: 6 additions & 0 deletions apps/desktop/src/main/harness-control.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,8 @@ export interface HarnessTargets {
/** The line the speech bubble cannot cross, and which side of the pet it is on. See
* PetWindow.bubbleBand. */
bubbleBand?: () => { y: number; side: 'above' | 'below'; visible: boolean }
/** Whether the pet is wearing a status crown. See PetWindow.crownVisible. */
crownVisible?: () => boolean
/** Whether the pet is floor-locked, so the harness knows if feet-on-floor is assertable. */
floorLocked?: () => boolean
/** Place the pet at an absolute position, as a drop would. */
Expand Down Expand Up @@ -125,6 +127,7 @@ export function installHarnessControl(targets: HarnessTargets): () => void {
const isPet = command.window === 'pet'
const rect = isPet ? targets.spriteRect?.() : undefined
const bubble = isPet ? targets.bubbleBand?.() : undefined
const crownVisible = isPet ? targets.crownVisible?.() : undefined
const floorLocked = isPet ? targets.floorLocked?.() : undefined
const petScale = isPet ? targets.petScale?.() : undefined
emit({
Expand All @@ -138,6 +141,7 @@ export function installHarnessControl(targets: HarnessTargets): () => void {
...(bubble === undefined
? {}
: { bubbleEdgeY: bubble.y, bubbleSide: bubble.side, bubbleVisible: bubble.visible }),
...(crownVisible === undefined ? {} : { crownVisible }),
...(floorLocked === undefined ? {} : { floorLocked }),
...(petScale === undefined ? {} : { petScale }),
})
Expand Down Expand Up @@ -209,6 +213,7 @@ export function installHarnessControl(targets: HarnessTargets): () => void {
return
}
const bubble = targets.bubbleBand?.()
const crownVisible = targets.crownVisible?.()
emit({
ev: 'geometry',
bounds: win.getContentBounds(),
Expand All @@ -218,6 +223,7 @@ export function installHarnessControl(targets: HarnessTargets): () => void {
...(bubble === undefined
? {}
: { bubbleEdgeY: bubble.y, bubbleSide: bubble.side, bubbleVisible: bubble.visible }),
...(crownVisible === undefined ? {} : { crownVisible }),
})
return
}
Expand Down
9 changes: 9 additions & 0 deletions apps/desktop/src/main/harness-handshake.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,14 @@ export type HandshakeEvent =
bubbleSide?: 'above' | 'below'
/** Whether a bubble was actually on screen for this capture. Pet window only. */
bubbleVisible?: boolean
/**
* Whether the pet was wearing a status crown for this capture. Pet window only.
*
* The crown floats above the head, so it paints in the ring A2 requires to be
* transparent. A2 is about the *window* being see-through; app-painted content there is
* not the bug it is looking for.
*/
crownVisible?: boolean
/**
* Whether the pet is sitting on the floor. False means it has been freely placed, in which
* case asserting feet-on-floor would fail on correct behaviour.
Expand All @@ -91,6 +99,7 @@ export type HandshakeEvent =
bubbleEdgeY?: number
bubbleSide?: 'above' | 'below'
bubbleVisible?: boolean
crownVisible?: boolean
}
| { ev: 'error'; where: string; message: string }

Expand Down
11 changes: 10 additions & 1 deletion apps/desktop/src/main/pet-controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ import { advance, initialState } from '../motion/motion-engine.js'
import { DEFAULT_MOTION_CONFIG, type MotionConfig } from '../motion/motion-config.js'
import type { MotionState, MotionTrigger } from '../motion/types.js'
import { ANIMATIONS, resolveTrigger, type Trigger, type AnimationState } from '../pet-animations.generated.js'
import type { PetFrame, Tone } from '../pet-frame.js'
import type { ClaudeState, PetFrame, Tone } from '../pet-frame.js'
import { DRINK_LOOP_GAP_MS } from '../config/constants.js'
import {
floorForWorkArea,
Expand All @@ -48,6 +48,13 @@ export interface PetControllerOptions {
pet: PetWindow
displays: DisplayManager
getMovementEnabled: () => boolean
/**
* Claude Code's state, for the status cap.
*
* Injected as a getter, like `getMovementEnabled`, so the controller stays ignorant of where
* the state comes from and the tests need no filesystem.
*/
getClaudeState?: () => ClaudeState
/** `feetY` is null when the pet is floor-locked, meaning "re-derive it on launch". */
onPositionChanged: (displayKey: string, petCentreX: number, feetY: number | null) => void
/** Fired after a tick when the pose changed, so the hover chip can return after electrocute. */
Expand Down Expand Up @@ -114,6 +121,7 @@ export interface PetController {
export function createPetController(options: PetControllerOptions): PetController {
const config = options.config ?? DEFAULT_MOTION_CONFIG
const now = options.now ?? Date.now
const getClaudeState = options.getClaudeState ?? ((): ClaudeState => 'none')
const log = options.log ?? (() => {})
const { pet, displays } = options

Expand Down Expand Up @@ -165,6 +173,7 @@ export function createPetController(options: PetControllerOptions): PetControlle
: null,
quickActions: callout ? [] : [...quickActions],
overlay: animation === config.sleepAnimation ? 'sleep-z' : 'none',
claudeState: getClaudeState(),
}
}

Expand Down
24 changes: 21 additions & 3 deletions apps/desktop/src/main/pet-window.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ import {
spriteScreenRect,
type BubbleSide,
} from '../sprite/alpha-mask.js'
import { IPC, petFrameSchema, type PetFrame } from '../pet-frame.js'
import { IPC, frameNeedsCellRegion, petFrameSchema, type PetFrame } from '../pet-frame.js'
import { rendererFile, paths } from './paths.js'
import { emit } from './harness-handshake.js'
import type { DisplaySnapshot, Floor } from './display-manager.js'
Expand Down Expand Up @@ -72,6 +72,13 @@ export interface PetWindow {
* assertion would then measure the bubble and report a transparency failure.
*/
bubbleBand(): { y: number; side: BubbleSide; visible: boolean }
/**
* Whether a status crown is on the pet's head right now.
*
* The harness needs it because the crown legitimately paints outside the character, in the
* ring A2 otherwise requires to be fully transparent.
*/
crownVisible(): boolean
/**
* Change the pet's size.
*
Expand Down Expand Up @@ -183,10 +190,16 @@ export async function createPetWindow(options: {
let lastAnimation: string | null = null
/** Whether the last frame carried a bubble. Drives the shape region and the callout raise. */
let lastBubbleVisible = false
/** Whether the last frame carried a CSS overlay — the sleep Z's. Drives the shape region. */
/**
* Whether the last frame painted inside the sprite cell but outside the character mask — the
* sleep Z's, or the Claude status cap. Drives the shape region, which on Linux governs what is
* painted at all.
*/
let lastOverlayVisible = false
/** Whether the hover quick-action menu is up. Drives the shape region on Linux. */
let lastQuickMenuVisible = false
/** The crown on the last frame, for the harness. 'none' means bare-headed. */
let lastClaudeState: PetFrame['claudeState'] = 'none'

/**
* The Linux input-and-drawing region for what is currently on screen.
Expand Down Expand Up @@ -335,7 +348,8 @@ export async function createPetWindow(options: {
const previous = { lastAnimation, lastBubbleVisible, lastOverlayVisible, lastQuickMenuVisible }
lastAnimation = parsed.data.animation
lastBubbleVisible = parsed.data.bubble !== null
lastOverlayVisible = parsed.data.overlay !== 'none'
lastOverlayVisible = frameNeedsCellRegion(parsed.data)
lastClaudeState = parsed.data.claudeState
lastQuickMenuVisible = parsed.data.quickActions.length > 0
win.webContents.send(IPC.frame, parsed.data)
forwarding.setForceInteractive(
Expand Down Expand Up @@ -369,6 +383,10 @@ export async function createPetWindow(options: {
return spriteScreenRect(ALPHA_MASK, bounds, placement.spriteOrigin, placement.scale) as Rectangle
},

crownVisible(): boolean {
return lastClaudeState !== 'none'
},

bubbleBand(): { y: number; side: BubbleSide; visible: boolean } {
const bounds = win.isDestroyed() ? { x, y } : win.getBounds()
const band = bubbleBandRect(
Expand Down
Loading
Loading