diff --git a/README.md b/README.md index 0f0951a..3df48f3 100644 --- a/README.md +++ b/README.md @@ -82,10 +82,21 @@ client, so hooks, shell aliases and editor tasks need no special entitlement. Lighting uses the vendor RPC method `v.oai.thstatus`, which takes a bare array of per-thread descriptors. Sending one entry updates one key and leaves the rest alone. -The daemon never touches your keymap. You bind the agent keycodes to a layer once, the -way you want them, and from then on the daemon only sends colours. The lighting settings are -independent of active layer: If you send a per-key state to a key in anothe layer, that key -will reflect its current status as soon as you switch that layer to be the active one. +The daemon normally never touches your keymap. You bind the agent keycodes to a layer once, the +way you want them, and from then on the daemon only sends colours. The `agentkeys demo` command +is the exception: it temporarily replaces the currently active layer with an all-agentic +layer, sends a random solid colour from the state palette to every supported agent slot every five +seconds for 30 seconds, then restores the exact original keymap before returning. + +The lighting settings are independent of active layer: If you send a per-key state to a key in +another layer, that key will reflect its current status as soon as you switch that layer to be the +active one. + +## CLI + +Use `agentkeys demo` to run the temporary all-agentic lighting demonstration. The command blocks +until the 30-second run and keymap restoration are complete; it requires the AgentKeys daemon to +be running and the keyboard to be connected. ## VS Code integration diff --git a/src/cli.ts b/src/cli.ts index fb09c08..c6ec046 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -12,6 +12,7 @@ const USAGE = `agentkeys - drive the Creator Micro 2 agent keys agentkeys set [label] set one slot (slot 0..${INTEGRATION_SLOT_COUNT - 1}) agentkeys status show all slots agentkeys reset set every slot to idle + agentkeys demo show a 30-second all-agentic color demo agentkeys list-states list valid state names agentkeys log follow the daemon log @@ -127,6 +128,10 @@ async function main(argv: string[]): Promise { await request('POST', '/reset'); return; + case 'demo': + await request('POST', '/demo'); + return; + case 'list-states': for (const name of Object.keys(STATES)) { const aliases = Object.entries(ALIASES) diff --git a/src/daemon-http.ts b/src/daemon-http.ts index 11155b1..bb5f741 100644 --- a/src/daemon-http.ts +++ b/src/daemon-http.ts @@ -24,6 +24,7 @@ export interface DaemonApi { /** `state` is already normalized; resolves to the slot as recorded, once the lighting is sent. */ setSlot(index: number, state: string, label: string | null): Promise; reset(): Promise; + demo(): Promise; resetVSCodeSlots(): Promise; } @@ -133,6 +134,11 @@ async function handle(api: DaemonApi, req: http.IncomingMessage, res: http.Serve return send(res, 200, { ok: true, slots: await api.reset() }); } + if (req.method === 'POST' && url.pathname === '/demo') { + await api.demo(); + return send(res, 200, { ok: true }); + } + const match = url.pathname.match(/^\/slots\/(\d+)$/); if (req.method === 'POST' && match) { const index = Number(match[1]); diff --git a/src/daemon.ts b/src/daemon.ts index 721d554..9f08529 100644 --- a/src/daemon.ts +++ b/src/daemon.ts @@ -2,8 +2,9 @@ import * as fs from 'fs'; import { Device, listDevices, type DeviceMessage, type NotifyHandler } from './device.js'; import { setThreads, setZones, EFFECT, type ThreadInput } from './oai.js'; import { STATES, INTEGRATION_SLOT_COUNT, DEFAULT_STATE } from './states.js'; +import { runDemo } from './demo.js'; import { VSCodeIntegration, type VSCodeSlot } from './vscode.js'; -import { readConfiguredAgentSlots } from './keymap.js'; +import { deviceLayerIndex, deviceProfileIndex, readConfiguredAgentSlots } from './keymap.js'; import { LocalAgentHostStateSource } from './agent-host.js'; import { createServer, HOST, PORT, type DaemonApi } from './daemon-http.js'; import type { Slot } from './daemon-interfaces.js'; @@ -37,6 +38,7 @@ let reconnectTimer: NodeJS.Timeout | null = null; let reconcileTimer: NodeJS.Timeout | null = null; let pushGeneration = 0; const pendingPushes = new Set>(); +let demoRunning: Promise | null = null; let shuttingDown = false; let visibleDeviceCount = 0; let deviceVisibilityKnown = false; @@ -67,7 +69,7 @@ function threadFor(slot: Slot): ThreadInput { */ function push(changed?: Slot): Promise { const current = device; - if (!current) return Promise.resolve(); + if (!current || demoRunning) return Promise.resolve(); const generation = ++pushGeneration; if (reconcileTimer) clearTimeout(reconcileTimer); reconcileTimer = null; @@ -253,6 +255,36 @@ async function reset(): Promise { return resetSlots; } +/** Runs the temporary all-agentic layer demo on the layer that is active now. */ +async function demo(): Promise { + if (demoRunning) throw new Error('demo already running'); + const current = device; + if (!current) throw new Error('keyboard disconnected; cannot run demo'); + + const operation = (async () => { + const status = await current.call('device.status'); + const profile = deviceProfileIndex(status); + const layer = deviceLayerIndex(status); + if (profile === null || profile < 0) throw new Error('keyboard did not report an active profile'); + if (layer === null || layer < 1) throw new Error('keyboard did not report an active layer'); + log(`demo targeting profile ${profile}, layer ${layer}`); + + pushGeneration++; + if (reconcileTimer) clearTimeout(reconcileTimer); + reconcileTimer = null; + while (pendingPushes.size) await Promise.allSettled([...pendingPushes]); + if (device !== current) throw new Error('keyboard disconnected before demo started'); + await runDemo(current, profile, layer); + })(); + demoRunning = operation; + try { + await operation; + } finally { + if (demoRunning === operation) demoRunning = null; + if (!shuttingDown && device === current) await push(); + } +} + /** Refreshes mapped AG indices from the connected keyboard, then frees every VS Code binding. */ async function resetVSCodeSlots(): Promise { const current = device; @@ -273,6 +305,7 @@ const api: DaemonApi = { slots: () => slots, setSlot, reset, + demo, resetVSCodeSlots, }; @@ -316,6 +349,7 @@ async function shutdown(signal: string): Promise { // A failed connection has no live handle to close. } } + if (demoRunning) await Promise.allSettled([demoRunning]); while (pendingPushes.size) await Promise.allSettled([...pendingPushes]); const current = device; diff --git a/src/demo.ts b/src/demo.ts new file mode 100644 index 0000000..906e7e0 --- /dev/null +++ b/src/demo.ts @@ -0,0 +1,67 @@ +import { EFFECT, setThreads, type DeviceLike, type ThreadInput } from './oai.js'; +import { INTEGRATION_SLOT_COUNT, STATES } from './states.js'; +import { CODEX_LAYER, withTemporaryCodexLayer, type LayerBuilder } from './keymap.js'; + +export const DEMO_DURATION_MS = 30_000; +export const DEMO_INTERVAL_MS = 5_000; + +const STATE_COLORS = Object.values(STATES).map(({ color }) => color); +const demoLayer: LayerBuilder = (original, index) => ({ + ...CODEX_LAYER, + id: index, + name: original.name, + lights: original.lights, + layout: { + ...CODEX_LAYER.layout, + keymap: [ + ['KV_OAI_AG00', 'KV_OAI_AG01'], + ['KV_OAI_AG02', 'KV_OAI_AG03', 'KV_OAI_AG04', 'KV_OAI_AG05'], + ['KV_OAI_AG06', 'KV_OAI_AG07', 'KV_OAI_AG08', 'KV_OAI_AG09'], + ['KV_OAI_AG10', 'KV_OAI_AG11', 'KV_OAI_AG12'], + ], + }, +}); + +export interface DemoOptions { + durationMs?: number; + intervalMs?: number; + random?: () => number; + sleep?: (ms: number) => Promise; +} + +export function demoThreads(random: () => number = Math.random): ThreadInput[] { + return Array.from({ length: INTEGRATION_SLOT_COUNT }, (_, id) => ({ + id, + color: STATE_COLORS[Math.min(STATE_COLORS.length - 1, Math.floor(random() * STATE_COLORS.length))], + effect: EFFECT.solid, + })); +} + +export async function runDemo( + device: DeviceLike, + profileIndex: number, + layerNumber: number, + options: DemoOptions = {} +): Promise { + const durationMs = options.durationMs ?? DEMO_DURATION_MS; + const intervalMs = options.intervalMs ?? DEMO_INTERVAL_MS; + const random = options.random ?? Math.random; + const sleep = options.sleep ?? ((ms: number) => new Promise((resolve) => setTimeout(resolve, ms))); + const steps = Math.ceil(durationMs / intervalMs); + + if (!Number.isFinite(durationMs) || durationMs <= 0) throw new Error('demo duration must be positive'); + if (!Number.isFinite(intervalMs) || intervalMs <= 0) throw new Error('demo interval must be positive'); + + await withTemporaryCodexLayer( + device, + profileIndex, + layerNumber, + async () => { + for (let step = 0; step < steps; step++) { + await setThreads(device, demoThreads(random)); + await sleep(intervalMs); + } + }, + demoLayer + ); +} diff --git a/src/keymap.ts b/src/keymap.ts index d7db08e..cdf5e76 100644 --- a/src/keymap.ts +++ b/src/keymap.ts @@ -1,6 +1,14 @@ import type { DeviceLike } from './oai.js'; import { INTEGRATION_SLOT_COUNT } from './states.js'; +export { + CODEX_LAYER, + deviceLayerIndex, + deviceProfileIndex, + withTemporaryCodexLayer, + type LayerBuilder, +} from './research/keymap.js'; + const KEYMAP_FILE = 'keymap.json'; const AGENT_KEYCODE = /^KV_OAI_AG(\d{2})$/; diff --git a/src/research/keymap.ts b/src/research/keymap.ts index fc4208b..9ff8a3f 100644 --- a/src/research/keymap.ts +++ b/src/research/keymap.ts @@ -3,9 +3,8 @@ import * as os from 'node:os'; import * as path from 'node:path'; import type { DeviceLike } from '../oai.js'; -// Research support only. The daemon never writes keymap.json - you bind the agent -// keycodes yourself, once. Everything here exists so test scripts can install a layer -// temporarily and be sure of putting the original back. +// Temporary keymap support for experiments and the CLI demo. All callers must use +// a wrapper that restores the original keymap instead of leaving a replacement live. const KEYMAP_FILE = 'keymap.json'; const MARKER = 'KV_OAI_AG00'; @@ -177,7 +176,64 @@ export async function withCodexLayer( } } +/** Replaces one live layer for the duration of a callback and restores its exact source text. */ +export async function withTemporaryCodexLayer( + device: DeviceLike, + profileIndex: number, + layerNumber: number, + fn: () => T | Promise, + buildLayer: LayerBuilder = codexLayer, + exitOnSignal = true +): Promise { + const original = await readKeymap(device); + const keymap = JSON.parse(original) as KeymapDocument; + const index = layerNumber - 1; + const layers = keymap.profiles[profileIndex]?.layers; + if (!layers) throw new Error(`profile ${profileIndex} has no layers`); + if (index < 0 || index >= layers.length) { + throw new Error(`layer ${layerNumber} does not exist (keymap has ${layers.length})`); + } + + layers[index] = buildLayer(layers[index], index); + const next = JSON.stringify(keymap); + let restoreNeeded = next !== original; + let restoring: Promise | null = null; + const restoreOnce = (): Promise => (restoring ??= (async () => { + if (!restoreNeeded) return; + await writeKeymap(device, original); + if ((await readKeymap(device)) !== original) throw new Error('keymap restore verification failed'); + restoreNeeded = false; + })()); + + const onSignal = (signal: NodeJS.Signals): void => { + restoreOnce().finally(() => { + if (exitOnSignal) process.exit(signal === 'SIGINT' ? 130 : 143); + }); + }; + process.on('SIGINT', onSignal); + process.on('SIGTERM', onSignal); + try { + if (restoreNeeded) { + await writeKeymap(device, next); + if ((await readKeymap(device)) !== next) throw new Error('temporary keymap verification failed'); + } + return await fn(); + } finally { + try { + await restoreOnce(); + } finally { + process.off('SIGINT', onSignal); + process.off('SIGTERM', onSignal); + } + } +} + export function deviceLayerIndex(status: unknown): number | null { if (typeof status !== 'object' || status === null || !('layer_index' in status)) return null; return typeof status.layer_index === 'number' ? status.layer_index : null; } + +export function deviceProfileIndex(status: unknown): number | null { + if (typeof status !== 'object' || status === null || !('profile_index' in status)) return null; + return typeof status.profile_index === 'number' ? status.profile_index : null; +} diff --git a/test/cli.test.mjs b/test/cli.test.mjs index 07de06f..1ef6c58 100644 --- a/test/cli.test.mjs +++ b/test/cli.test.mjs @@ -1,6 +1,8 @@ import assert from 'node:assert/strict'; import { spawn } from 'node:child_process'; +import { once } from 'node:events'; import fs from 'node:fs'; +import http from 'node:http'; import os from 'node:os'; import path from 'node:path'; import test from 'node:test'; @@ -46,4 +48,26 @@ test('log follows the daemon log under the user home directory', async () => { } finally { fs.rmSync(directory, { recursive: true, force: true }); } +}); + +test('demo asks the daemon to run the demo', async () => { + const calls = []; + const server = http.createServer((req, res) => { + calls.push([req.method, req.url]); + res.writeHead(200, { 'content-type': 'application/json' }); + res.end(JSON.stringify({ ok: true })); + }); + server.listen(0, '127.0.0.1'); + await once(server, 'listening'); + const { port } = server.address(); + + try { + const result = await runCli(['demo'], { AGENTKEYS_PORT: String(port) }); + assert.equal(result.code, 0, result.stderr); + assert.equal(result.stdout, ''); + assert.deepEqual(calls, [['POST', '/demo']]); + } finally { + server.close(); + await once(server, 'close'); + } }); \ No newline at end of file diff --git a/test/daemon-http.test.mjs b/test/daemon-http.test.mjs index d1e1e59..166e29c 100644 --- a/test/daemon-http.test.mjs +++ b/test/daemon-http.test.mjs @@ -40,6 +40,9 @@ function fakeApi({ vscode, ...overrides } = {}) { calls.push(['reset']); return [slot(0, 'idle'), slot(1, 'idle')]; }, + demo: async () => { + calls.push(['demo']); + }, resetVSCodeSlots: async () => { calls.push(['resetVSCodeSlots']); return [{ slot: 0, state: 'idle' }]; @@ -77,6 +80,7 @@ const server = createServer({ slots: () => api.slots(), setSlot: (...args) => api.setSlot(...args), reset: () => api.reset(), + demo: () => api.demo(), resetVSCodeSlots: () => api.resetVSCodeSlots(), }); server.listen(PORT, HOST); @@ -153,6 +157,15 @@ test('POST /reset returns the slots the api recorded', async () => { assert.deepEqual(api.calls, [['reset']]); }); +test('POST /demo delegates to the demo operation', async () => { + api = fakeApi(); + + const response = await post('/demo'); + assert.equal(response.status, 200); + assert.deepEqual(await response.json(), { ok: true }); + assert.deepEqual(api.calls, [['demo']]); +}); + test('VS Code routes delegate to the integration', async () => { api = fakeApi(); diff --git a/test/demo.test.mjs b/test/demo.test.mjs new file mode 100644 index 0000000..c550453 --- /dev/null +++ b/test/demo.test.mjs @@ -0,0 +1,74 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { EFFECT } from '../dist/oai.js'; +import { demoThreads, runDemo } from '../dist/demo.js'; +import { STATES } from '../dist/states.js'; + +const PALETTE = Object.values(STATES).map(({ color }) => color); + +function originalKeymap() { + return JSON.stringify({ + profiles: [ + { layers: [{ id: 0, name: 'Profile 0', layout: { encoders: [], buttons: [], keymap: [['KC_A']] } }] }, + { layers: [{ id: 0, name: 'Profile 1', layout: { encoders: [], buttons: [], keymap: [['KC_B']] } }] }, + ], + }); +} + +test('demo generates one solid state color for every agent slot', () => { + const threads = demoThreads(() => 0.4); + + assert.equal(threads.length, 20); + assert.deepEqual(threads.map(({ id }) => id), Array.from({ length: 20 }, (_, id) => id)); + assert(threads.every(({ color, effect }) => PALETTE.includes(color) && effect === EFFECT.solid)); +}); + +test('demo restores the exact original keymap after its run', async () => { + const original = originalKeymap(); + let live = original; + const calls = []; + const device = { + call: async (method, params) => { + calls.push([method, params]); + if (method === 'fs.read') return { data: live }; + if (method === 'fs.write') { + live = params.data; + return { ok: 1 }; + } + if (method === 'v.oai.thstatus') return { ok: 1 }; + throw new Error(`unexpected method ${method}`); + }, + }; + + await runDemo(device, 1, 1, { + durationMs: 1, + intervalMs: 1, + random: () => 0.4, + sleep: async () => {}, + }); + + assert.equal(live, original); + const writes = calls.filter(([method]) => method === 'fs.write'); + assert.equal(writes.length, 2); + const installed = JSON.parse(writes[0][1].data); + assert.deepEqual(installed.profiles[0].layers[0].layout.keymap, [['KC_A']]); + assert.deepEqual(installed.profiles[1].layers[0].layout.keymap.flat(), [ + 'KV_OAI_AG00', + 'KV_OAI_AG01', + 'KV_OAI_AG02', + 'KV_OAI_AG03', + 'KV_OAI_AG04', + 'KV_OAI_AG05', + 'KV_OAI_AG06', + 'KV_OAI_AG07', + 'KV_OAI_AG08', + 'KV_OAI_AG09', + 'KV_OAI_AG10', + 'KV_OAI_AG11', + 'KV_OAI_AG12', + ]); + const lighting = calls.find(([method]) => method === 'v.oai.thstatus'); + assert(lighting); + assert.equal(lighting[1].length, 20); + assert(lighting[1].every(({ e, c }) => e === EFFECT.solid && c === PALETTE[2])); +});