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
40 changes: 40 additions & 0 deletions server/src/agent-list.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
// Session and group lookup by human-typed names — the roster's `?group=`
// filter, the spawn route's `group=` target, the cron "which session runs this
// job" lookup.
//
// Names here are things people and agents type: `?group=hunter` for a group the
// operator called "Hunter", a cron pointing at "Reviewer" when the pane was
// renamed "reviewer". An exact comparison turned every one of those into a
// silent empty result (or, for crons, a duplicate session spawned beside the
// one it was meant to reuse). Both sides of every comparison go through
// normalizeName() so the rule lives in one place: trim, fold case, and NFC so
// a composed "é" matches a decomposed one.

export function normalizeName(value) {
if (value === null || value === undefined) return '';
return String(value).normalize('NFC').trim().toLocaleLowerCase('en');
}

/** True when both are non-blank and equal after normalizeName(). */
export const sameName = (a, b) => {
const left = normalizeName(a);
return left !== '' && left === normalizeName(b);
};

/** Preserve exact targets; refuse ambiguous normalized names instead of guessing. */
export function findByName(items, name) {
const wanted = normalizeName(name);
if (!wanted) return null;
const exact = items.filter((item) => item?.name === name);
if (exact.length === 1) return exact[0];
const matches = items.filter((item) => normalizeName(item && item.name) === wanted);
if (matches.length > 1) throw new Error(`ambiguous name '${name}'; use an exact unique name`);
return matches[0] || null;
}

/** Sessions of the group called `group` (case-insensitive); no filter = all rows. */
export function filterAgentsByGroup(rows, group) {
const wanted = normalizeName(group);
if (!wanted) return rows;
return rows.filter((row) => normalizeName(row.group) === wanted);
}
8 changes: 5 additions & 3 deletions server/src/groups.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import fs from 'node:fs';
import path from 'node:path';
import crypto from 'node:crypto';
import { DATA_DIR } from './config.js';
import { findByName, normalizeName } from './agent-list.js';

const GROUPS_FILE = path.join(DATA_DIR, 'groups.json');
let groups = [];
Expand Down Expand Up @@ -110,11 +111,12 @@ export function resolveSpawnGroup(requested, callerId) {
const g = groupOf(callerId);
return { groupId: g ? g.id : null };
}
if (raw.toLowerCase() === 'none') return { groupId: null };
if (normalizeName(raw) === 'none') return { groupId: null };
const byId = get(raw);
if (byId) return { groupId: byId.id };
const lower = raw.toLowerCase();
const byName = groups.find((g) => (g.name || '').toLowerCase() === lower);
let byName;
try { byName = findByName(groups, raw); }
catch (e) { return { error: e.message }; }
if (byName) return { groupId: byName.id };
const known = groups.map((g) => g.name).filter(Boolean).join(', ');
return { error: `unknown group '${raw}'${known ? ` — groups here: ${known}` : ' — no groups exist yet'}; pass group=none to stay ungrouped` };
Expand Down
11 changes: 8 additions & 3 deletions server/src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ import {
import * as runstate from './runstate.js';
import { installSlowFsProbe } from './slowfs.js';
import { operationMiddleware, readOperations } from './operations.js';
import { findByName, filterAgentsByGroup } from './agent-list.js';

// Before anything else touches the mount: a sync fs call to /data is ~85ms of
// frozen event loop here, and nothing else in the stack can see it. See slowfs.js.
Expand Down Expand Up @@ -602,6 +603,9 @@ function agentRow(s, act, d, selfId, mates) {
}

app.get('/api/agents', async (req, res) => {
// `?group=` narrows the roster to one sidebar group, by name, ignoring case.
const group = req.query.group === undefined ? null : String(req.query.group).trim();
if (group !== null && (!group || group.length > 120)) return res.status(400).json({ error: 'group filter is invalid' });
const info = agentInfo();
const digests = await traceDigests();
const selfId = String(req.query.from || req.query.self || '').trim() || null;
Expand All @@ -619,7 +623,7 @@ app.get('/api/agents', async (req, res) => {
agents.push(row);
}
res.json({
agents,
agents: filterAgentsByGroup(agents, group),
// Which CLIs a spawn can ask for. `ready` = a credential was found.
// Remote agents are absent from the spawn list on purpose: creating one
// produces a pane waiting for a human to paste its prompt onto another
Expand Down Expand Up @@ -1404,7 +1408,8 @@ curl -s "http://localhost:\${AM_PORT:-${PORT}}/api/agents?from=$AM_ID" | jq .
Each entry carries \`id\`, \`name\`, \`cli\`, \`state\`, \`workdir\`, \`sharesFolderWith\`,
a one-line \`lastPrompt\`/\`lastAnswer\`, \`recentFiles\`, and \`trace\` — the path to
its raw conversation log, which you can read directly with \`jq\` when you need
the full history rather than a summary. \`GET /api/agents/$ID\` adds the full
the full history rather than a summary. \`?group=<name>\` keeps only one sidebar
group (name matched ignoring case). \`GET /api/agents/$ID\` adds the full
digest for one agent. Read \`state\` before you do anything:

- \`working\` — thinking or running a tool right now. **Leave it alone.**
Expand Down Expand Up @@ -2350,7 +2355,7 @@ function beginCronFire(job, trigger) {
};

try {
let session = store.list().find((candidate) => candidate.name === job.agent.name) || null;
let session = findByName(store.list(), job.agent.name);
let agentCreated = false;
if (!session) {
const invalid = cronCliError(job.agent.cli);
Expand Down
89 changes: 89 additions & 0 deletions server/test/agent-list.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
// Session and group lookup by name is case-insensitive.
//
// `GET /api/agents?group=hunter` must find a group the operator named "Hunter",
// `POST /api/agents group=hunter` must land in it, and a cron pointing at
// "Reviewer" must reuse the existing "reviewer" session instead of spawning a
// second one. Every name comparison goes through normalizeName() (trim,
// case-fold, NFC) on both the query and the stored side.
// Run with: node test/agent-list.test.mjs
import fs from 'node:fs';
import path from 'node:path';
import os from 'node:os';
import assert from 'node:assert/strict';
import { filterAgentsByGroup, findByName, normalizeName, sameName } from '../src/agent-list.js';

const TMP = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-list-'));
process.env.DATA_DIR = path.join(TMP, 'data');
fs.mkdirSync(process.env.DATA_DIR, { recursive: true });
const groups = await import('../src/groups.js');
groups.init();

let pass = 0, fail = 0;
const check = (name, got, want) => {
const ok = got === want;
ok ? pass++ : fail++;
console.log(` ${ok ? 'PASS' : 'FAIL'} ${name}${ok ? '' : `\n got ${JSON.stringify(got)} want ${JSON.stringify(want)}`}`);
};
const ids = (rows) => rows.map((r) => r.id).join(',');

console.log('\nnormalizeName folds the things people type differently');
check('case', normalizeName('Hunter'), 'hunter');
check('outer whitespace', normalizeName(' Hunter '), 'hunter');
check('unicode case', normalizeName('ÉQUIPE'), 'équipe');
check('NFC vs NFD', normalizeName('Équipe'), normalizeName('Équipe'));
check('null is empty', normalizeName(null), '');
check('sameName never matches two blanks', sameName(' ', ''), false);
check('sameName ignores case', sameName('Hunter', 'HUNTER'), true);

const rows = [
{ id: 'a', name: 'Alpha', group: 'Hunter' },
{ id: 'b', name: 'Beta', group: 'hunter' },
{ id: 'c', name: 'Gamma', group: 'Co-write' },
{ id: 'd', name: 'Delta', group: null },
{ id: 'e', name: 'Épsilon', group: 'Équipe' },
];

console.log('\nroster group filter (GET /api/agents?group=)');
check('exact name still works', ids(filterAgentsByGroup(rows, 'Hunter')), 'a,b');
check('lower-case query finds the mixed-case group', ids(filterAgentsByGroup(rows, 'hunter')), 'a,b');
check('upper-case query too', ids(filterAgentsByGroup(rows, 'HUNTER')), 'a,b');
check('padded query is trimmed', ids(filterAgentsByGroup(rows, ' co-WRITE ')), 'c');
check('accented group, decomposed query', ids(filterAgentsByGroup(rows, 'équipe')), 'e');
check('unknown group is empty, not everything', ids(filterAgentsByGroup(rows, 'nope')), '');
check('no filter returns all', filterAgentsByGroup(rows, null).length, rows.length);
check('blank filter returns all', filterAgentsByGroup(rows, ' ').length, rows.length);
check('ungrouped rows never match a filter', ids(filterAgentsByGroup(rows, 'null')), '');

console.log('\nsession lookup by name (cron reuse)');
const sessions = [
{ id: 's1', name: 'Reviewer' },
{ id: 's2', name: 'daily digest' },
{ id: 's3', name: 'reviewer' },
];
check('exact match', findByName(sessions, 'Reviewer')?.id, 's1');
check('case-insensitive unique match', findByName([sessions[0]], 'REVIEWER')?.id, 's1');
check('exact match wins when two names collide by case', findByName(sessions, 'reviewer')?.id, 's3');
assert.throws(() => findByName(sessions, 'REVIEWER'), /ambiguous name/);
assert.throws(() => findByName([{ name: 'same' }, { name: 'same' }], 'same'), /ambiguous name/);
check('trimmed and folded', findByName(sessions, ' Daily Digest ')?.id, 's2');
check('unknown name is null', findByName(sessions, 'nobody'), null);
check('empty name never matches', findByName([{ id: 'x', name: '' }, ...sessions], ''), null);
check('undefined name is null, not a crash', findByName(sessions, undefined), null);
check('sessions without a name are skipped', findByName([{ id: 'n' }, ...sessions], 'reviewer')?.id, 's3');

console.log('\nspawn target group by name (POST /api/agents group=)');
const hunter = groups.create('Hunter');
const equipe = groups.create('Équipe');
check('exact', groups.resolveSpawnGroup('Hunter', 'x').groupId, hunter.id);
check('lower-case', groups.resolveSpawnGroup('hunter', 'x').groupId, hunter.id);
check('upper-case padded', groups.resolveSpawnGroup(' HUNTER ', 'x').groupId, hunter.id);
check('decomposed accent', groups.resolveSpawnGroup('équipe', 'x').groupId, equipe.id);
check('NONE still means ungrouped', groups.resolveSpawnGroup('NONE', 'x').groupId, null);
check('unknown is still an error', typeof groups.resolveSpawnGroup('nope', 'x').error, 'string');
const otherHunter = groups.create('hunter');
check('exact group still wins', groups.resolveSpawnGroup('hunter', 'x').groupId, otherHunter.id);
check('ambiguous group is an error', groups.resolveSpawnGroup('HUNTER', 'x').error.includes('ambiguous'), true);

fs.rmSync(TMP, { recursive: true, force: true });
console.log(`\n${pass} passed, ${fail} failed`);
process.exit(fail ? 1 : 0);
3 changes: 3 additions & 0 deletions server/test/crons.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,9 @@ test('jobs persist, stop without deletion, resume from now, and never replay sta
});

test('run on restart fires once for enabled running jobs, not stopped ones', async () => {
// The previous case used a historical clock. Reload at the real startup
// time before arming real timers, otherwise its overdue job also fires.
crons.init();
const running = crons.get(crons.list()[0].id);
assert.equal(running.runOnRestart, true);
const stopped = crons.create({
Expand Down
4 changes: 2 additions & 2 deletions server/test/spawn-group.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -66,10 +66,10 @@ check('object falls back to inherit', groups.resolveSpawnGroup({ x: 1 }, CALLER)

// The skill tells agents to create a group and then spawn into the id it
// returns, rather than the name. This is why: nothing dedupes group names.
console.log('\nduplicate names resolve to the first match; ids stay exact');
console.log('\nduplicate names are refused; ids stay exact');
const dupe = groups.create('Co-write');
check('two groups can share a name', groups.list().filter((x) => x.name === 'Co-write').length, 2);
check('by name takes the first', groups.resolveSpawnGroup('Co-write', LONER).groupId, cowrite.id);
check('by name reports ambiguity', groups.resolveSpawnGroup('Co-write', LONER).error.includes('ambiguous'), true);
check('by id reaches the second', groups.resolveSpawnGroup(dupe.id, LONER).groupId, dupe.id);
groups.remove(dupe.id);

Expand Down