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
19 changes: 15 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
5 changes: 5 additions & 0 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ const USAGE = `agentkeys - drive the Creator Micro 2 agent keys
agentkeys set <slot> <state> [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

Expand Down Expand Up @@ -127,6 +128,10 @@ async function main(argv: string[]): Promise<void> {
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)
Expand Down
6 changes: 6 additions & 0 deletions src/daemon-http.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Slot>;
reset(): Promise<Slot[]>;
demo(): Promise<void>;
resetVSCodeSlots(): Promise<VSCodeSlot[]>;
}

Expand Down Expand Up @@ -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]);
Expand Down
38 changes: 36 additions & 2 deletions src/daemon.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -37,6 +38,7 @@ let reconnectTimer: NodeJS.Timeout | null = null;
let reconcileTimer: NodeJS.Timeout | null = null;
let pushGeneration = 0;
const pendingPushes = new Set<Promise<void>>();
let demoRunning: Promise<void> | null = null;
let shuttingDown = false;
let visibleDeviceCount = 0;
let deviceVisibilityKnown = false;
Expand Down Expand Up @@ -67,7 +69,7 @@ function threadFor(slot: Slot): ThreadInput {
*/
function push(changed?: Slot): Promise<void> {
const current = device;
if (!current) return Promise.resolve();
if (!current || demoRunning) return Promise.resolve();
const generation = ++pushGeneration;
if (reconcileTimer) clearTimeout(reconcileTimer);
reconcileTimer = null;
Expand Down Expand Up @@ -253,6 +255,36 @@ async function reset(): Promise<Slot[]> {
return resetSlots;
}

/** Runs the temporary all-agentic layer demo on the layer that is active now. */
async function demo(): Promise<void> {
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<VSCodeSlot[]> {
const current = device;
Expand All @@ -273,6 +305,7 @@ const api: DaemonApi = {
slots: () => slots,
setSlot,
reset,
demo,
resetVSCodeSlots,
};

Expand Down Expand Up @@ -316,6 +349,7 @@ async function shutdown(signal: string): Promise<void> {
// 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;
Expand Down
67 changes: 67 additions & 0 deletions src/demo.ts
Original file line number Diff line number Diff line change
@@ -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<void>;
}

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<void> {
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<void>((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
);
}
8 changes: 8 additions & 0 deletions src/keymap.ts
Original file line number Diff line number Diff line change
@@ -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})$/;

Expand Down
62 changes: 59 additions & 3 deletions src/research/keymap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -177,7 +176,64 @@ export async function withCodexLayer<T>(
}
}

/** Replaces one live layer for the duration of a callback and restores its exact source text. */
export async function withTemporaryCodexLayer<T>(
device: DeviceLike,
profileIndex: number,
layerNumber: number,
fn: () => T | Promise<T>,
buildLayer: LayerBuilder = codexLayer,
exitOnSignal = true
): Promise<T> {
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<void> | null = null;
const restoreOnce = (): Promise<void> => (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;
}
24 changes: 24 additions & 0 deletions test/cli.test.mjs
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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');
}
});
13 changes: 13 additions & 0 deletions test/daemon-http.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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' }];
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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();

Expand Down
Loading