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
8 changes: 6 additions & 2 deletions docs/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -638,7 +638,7 @@ All paths are under `/api/v1`.
| GET | `/remote-access` | | `{ remoteAccess }`: `revision`, `mode`, `busy`, `pending`, `problem` and connection details. `url` is the active address. `candidate` is the address enabling would use with Tailscale's current settings, `{ url, existing, remembered, standardUrl? }`: `remembered` when it is the address used before, and `standardUrl` when `standard: true` would choose another. |
| POST | `/remote-access/check` | | `{ remoteAccess }` after refreshing Tailscale's status. |
| PUT | `/remote-access` | `{ revision, action, adopt?, hosts?, origins? }` | `202`. `action` is `enable` (`adopt: true` manages a matching existing route; `standard: true`, only while remote access is off, skips the remembered address and takes port 443, else the next free port), `disable`, `custom` (with `hosts` and `origins`) or `forget` (leave an inactive route awaiting cleanup in Tailscale). Poll until `busy` is null and read `problem`. 409 `stale_settings` for an old `revision`. Needs the API token. Revoked WebSockets close with code 4403; their terminals keep running. |
| GET | `/folders?path=&files=` | | `{ path, parent, home, segments, roots, entries, truncated, note }`: the subfolders of `path` (blank or `~` for the home folder). `entries` are `{ name, path, hidden }`. A missing `path` lists the nearest folder above it and names the requested path in `note`. `files=agworld` adds `files`, the world files in that folder, each `{ name, path }`. |
| GET | `/folders?path=&files=&pieces=` | | `{ path, parent, home, segments, roots, entries, truncated, note }`: the subfolders of `path` (blank or `~` for the home folder). `entries` are `{ name, path, hidden }`. A missing `path` lists the nearest folder above it and names the requested path in `note`. `files=agworld` adds `files`, the world files in that folder, each `{ name, path }`. `pieces=character` or `pieces=companion` adds `pieces`, the matching pieces in that folder and the folders directly inside it, each `{ title, folder, path }`, sorted by title, with `problem` when the piece needs a newer Agent Guild, and `workshop`, the Steam Workshop folder of Agent Guild: World found from Steam's library records, or null. |
| POST | `/folders` | `{ path, name }` | 201 with the `GET /folders` listing of the new folder `name`, made inside `path`. |
| GET | `/autostart` | | `{ autostart: { available, enabled, reason, note?, lastRun?, log? } }`: whether the manager starts when the user signs in. `reason` says why it is unavailable. `lastRun` is null or `{ at, outcome }`. On Linux outside WSL it also has `mode` (`off`, `sign-in`, `boot` or `both`) and `boot`, the systemd service's `{ available, enabled, reason, note, user, linger, state, commands }`. |
| PUT | `/autostart` | `{ enabled }` or `{ mode }` | `{ autostart }` as above. `mode` (`off`, `sign-in` or `boot`) makes that the one way the manager starts; `boot` is offered only on Linux with systemd. |
Expand Down Expand Up @@ -669,6 +669,9 @@ All paths are under `/api/v1`.
| GET | `/yard-worlds` | | `{ yardWorlds }`: the world replacing each Yard, by skin. A world is `{ title, revision, lights }`, or `{ title, problem }` when it cannot be used, in which case the Yard shows its original. A Yard with no entry shows its original. |
| PUT | `/yard-worlds/:skin` | `{ path }` | `{ yardWorlds }`: checks the `.agworld` file at `path` on the manager's computer and makes it this Yard's world, replacing any before it. 400 `world_invalid` says what is wrong and leaves the Yard unchanged; 404 `unknown_yard`; 409 `world_busy` while another install runs; 409 `worlds_unsupported` without a data folder; 500 `world_unwritable`. Its plates and skies are served at `/yard/worlds/:skin/:file`. |
| DELETE | `/yard-worlds/:skin` | | `{ yardWorlds }`: deletes this Yard's world, so it shows its original. |
| GET | `/yard-pieces` | | `{ yardPieces }`: the pieces replacing each Yard's characters and companions, by skin, then by slot: a crew id (`anthropic`, `openai`, `google`, `xai`, `shell`, `docker`) or `companion`. A piece is `{ title, revision }`, or `{ title, problem }` when it cannot be used, in which case the Yard shows its original. Terminal AI Chat wears the `shell` piece, gilded. |
| PUT | `/yard-pieces/:skin/:slot` | `{ path }` | `{ yardPieces }`: copies the piece folder at `path` on the manager's computer and makes it this slot's piece, replacing any before it. The folder holds `piece.json` (`format: 1`, `kind` `character` or `companion`, optional `title`) and `character.glb`, or all four of `companion_0.glb` to `companion_3.glb`; the models follow the Yard's skeleton, animations and `yard` extras, and are checked by drawing them with the Yard's own loader; their sizes, joints, clip lengths and `yard` extras must fall within the limits in `src/manager/yard-piece-check.mjs` and `yardExtras` in `web/yard/model.mjs`. 400 `piece_invalid` says what is wrong and leaves the slot unchanged; 404 `unknown_yard` or `unknown_slot`; 409 `piece_busy` while another change runs; 409 `pieces_unsupported` without a data folder; 500 `piece_unwritable`. Its models are served at `/yard/pieces/:skin/:slot/:file`. |
| DELETE | `/yard-pieces/:skin/:slot` | | `{ yardPieces }`: deletes this slot's piece, so it shows its original. 500 `piece_unwritable` leaves the piece in place. |
| POST | `/companion/install` | | `201 { session }`: starts a session that downloads the latest Grove Companion, checks its signature, installs it and starts it. 400 `companion_unsupported` unless the manager runs on Windows or macOS; 409 `install_in_progress` while one runs. |
| POST | `/yard/fireworks` | | `{ ok, pages }`: fireworks over the Yard of every open page, sent as `yard.fireworks`. `pages` is how many pages were listening. |
| GET | `/changelog` | | `{ refreshing, okAt, error, releases }`: Agent Guild's own releases, newest first, each `{ version, url, publishedAt, sections: [{ title, changes }] }`, where `title` may be null and a change is a list of text runs. A `changelog.updated` event follows a refresh. |
Expand Down Expand Up @@ -746,7 +749,7 @@ but `{ type: "ping" }`, which the manager answers with `{ type: "pong" }`.

| Message | Meaning |
| --- | --- |
| `{ type: "hello", version, pid, platform, startedAt, launcher, folderOpener, remoteAccess, upgrade, yardArt, yardWorlds, sessions, heartbeat, notesRevision, notesUnreadable }` | Sent first: the manager (as in `/info`), the full session list and the notepad's revision. |
| `{ type: "hello", version, pid, platform, startedAt, launcher, folderOpener, remoteAccess, upgrade, yardArt, yardWorlds, yardPieces, sessions, heartbeat, notesRevision, notesUnreadable }` | Sent first: the manager (as in `/info`), the full session list and the notepad's revision. |
| `{ type: "session.created", session }` | A session was started by any client. |
| `{ type: "session.updated", session }` | Status, activity, agents, name or size changed. |
| `{ type: "session.removed", sessionId }` | A session was removed. |
Expand All @@ -755,6 +758,7 @@ but `{ type: "ping" }`, which the manager answers with `{ type: "pong" }`.
| `{ type: "news.updated" }` | Fetch `/news` again. |
| `{ type: "yard-art.updated", yardArt }` | The Yard's art changed state or made download progress, as in `/yard-art`. |
| `{ type: "yard-worlds.updated", yardWorlds }` | A Yard's world was replaced or deleted, as in `/yard-worlds`. |
| `{ type: "yard-pieces.updated", yardPieces }` | A Yard's character or companion was replaced or deleted, as in `/yard-pieces`. |
| `{ type: "yard.fireworks" }` | Fireworks over the Yard, when it is on show. |
| `{ type: "changelog.updated" }` | Fetch `/changelog` again. |
| `{ type: "github.updated" }` | Fetch `/github` again. |
Expand Down
2 changes: 1 addition & 1 deletion docs/yard/CONTRACT.md
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,7 @@ has posed them, about the model's own up and right axes, the neck taking
`STAGING.look.yaw` (0.7 rad) either way and `STAGING.look.pitch` (0.3 rad)
up or down, easing in and out. A model whose head tears at those limits
carries narrower ones as `yard.look` (`{"yaw", "pitch"}`, in radians), and a
client turns it no further (`lookLimits(yard)`). A walking character does
client turns it no further (`lookLimits(yardExtras(yard, 'character').look)`). A walking character does
not look. With reduced motion a character does not glance.

## Clip choices
Expand Down
4 changes: 2 additions & 2 deletions docs/yard/fixture.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { randomBytes } from 'node:crypto';
import { fileURLToPath } from 'node:url';
import { createManagerServer } from '../../src/manager/server.mjs';

export async function startFixture({port=0,yardArt,yardWorlds}={}) {
export async function startFixture({port=0,yardArt,yardWorlds,yardPieces}={}) {
const calls=[],data=new Map(),terminals=new Map();
let next=16;
const now=()=>new Date().toISOString();
Expand Down Expand Up @@ -96,7 +96,7 @@ export async function startFixture({port=0,yardArt,yardWorlds}={}) {
agents:i===0?[{id:'a1',name:'Explorer',status:'working',detail:'Reading the project'},{id:'a2',name:'Reviewer',status:'waiting',detail:'Waiting for input'}]:i===1?[{id:'a3',name:'Designer',status:'working'}]:[],
shells:i===4?[{id:'sh1'}]:[]},false));
const token=randomBytes(24).toString('hex');
const api=createManagerServer({manager,registry,usage,history,modelStats,news,changelog,github,selfUpdate,token,port,webDir:fileURLToPath(new URL('../../web',import.meta.url)),version:'1.0.0',...(yardArt?{yardArt}:{}),...(yardWorlds?{yardWorlds}:{}),
const api=createManagerServer({manager,registry,usage,history,modelStats,news,changelog,github,selfUpdate,token,port,webDir:fileURLToPath(new URL('../../web',import.meta.url)),version:'1.0.0',...(yardArt?{yardArt}:{}),...(yardWorlds?{yardWorlds}:{}),...(yardPieces?{yardPieces}:{}),
onShutdownRequest:({restart})=>{calls.push(['shutdown',restart]);manager.emit('event',{type:'manager.stopped',remaining:0,restart});if(restart){data.clear();manager.closing=false;setTimeout(()=>manager.emit('event',{type:'hello',version:'1.1.0',pid:process.pid,sessions:[],upgrade:null}),100);}}
});
await api.listen();
Expand Down
37 changes: 37 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,9 @@
"test": "node --test --test-concurrency=1 \"tests/*.test.mjs\""
},
"dependencies": {
"@jsquash/jpeg": "1.6.0",
"@jsquash/png": "3.1.1",
"@jsquash/webp": "1.5.0",
"@noble/hashes": "2.4.0",
"@xterm/addon-fit": "^0.11.0",
"@xterm/addon-serialize": "^0.14.0",
Expand Down
1 change: 1 addition & 0 deletions src/manager/config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ export const paths = {
/** The Yard's art packs, one folder per content id. */
get yardArt() { return path.join(dataDir(), 'yard-art'); },
get yardWorlds() { return path.join(dataDir(), 'yard-worlds'); },
get yardPieces() { return path.join(dataDir(), 'yard-pieces'); },
};

function writePrivate(file, contents) {
Expand Down
33 changes: 31 additions & 2 deletions src/manager/folder-browser.mjs
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { readManifest } from './yard-pieces.mjs';

const failure = (status, code, message) => Object.assign(new Error(message), { status, code });
const PIECE_SCAN = 500;
const byName = (a, b) => a.name.localeCompare(b.name, undefined, { sensitivity: 'base', numeric: true }) || (a.name < b.name ? -1 : 1);

export function folderSegments(dir, paths = path) {
Expand Down Expand Up @@ -30,7 +32,7 @@ export function folderNameProblem(name, platform = process.platform) {
return null;
}

export function createFolderBrowser({ platform = process.platform, home = os.homedir(), limit = 2000, driveTimeoutMs = 500, fsp = fs.promises } = {}) {
export function createFolderBrowser({ platform = process.platform, home = os.homedir(), limit = 2000, driveTimeoutMs = 500, fsp = fs.promises, workshop = null } = {}) {
const paths = platform === 'win32' ? path.win32 : path.posix;

async function roots() {
Expand Down Expand Up @@ -79,6 +81,31 @@ export function createFolderBrowser({ platform = process.platform, home = os.hom
.map((entry) => ({ name: entry.name, path: paths.join(dir, entry.name) })).sort(byName);
}

async function pieceIn(dir, kind) {
const file = paths.join(dir, 'piece.json');
try {
const stat = await fsp.lstat(file);
if (!stat.isFile() || stat.size > 16 * 1024) return null;
const bytes = await fsp.readFile(file), folder = paths.basename(dir);
try {
const { title } = readManifest(bytes, kind);
return { title: title || folder, folder, path: dir };
} catch (err) {
const meta = err.newer ? JSON.parse(bytes.toString('utf8')) : null;
if (meta?.kind !== kind) return null;
const title = typeof meta.title === 'string' && meta.title.length <= 128 && !/\p{Cc}/u.test(meta.title) ? meta.title.trim() : '';
return { title: title || folder, folder, path: dir, problem: err.message };
}
} catch {
return null;
}
}

async function piecesIn(dir, folders, kind) {
const found = await Promise.all([dir, ...folders.slice(0, PIECE_SCAN).map((entry) => entry.path)].map((folder) => pieceIn(folder, kind)));
return found.filter(Boolean).sort((a, b) => a.title.localeCompare(b.title, undefined, { sensitivity: 'base', numeric: true }) || (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));
}

const browser = {
async create(rawParent, rawName) {
if (typeof rawParent !== 'string' || !rawParent.trim() || rawParent.includes('\0')) throw failure(400, 'bad_path', 'The folder must be a path.');
Expand All @@ -103,7 +130,7 @@ export function createFolderBrowser({ platform = process.platform, home = os.hom
}
return browser.list(dir);
},
async list(raw, { extension = null } = {}) {
async list(raw, { extension = null, pieces = null } = {}) {
if (raw !== undefined && raw !== null && (typeof raw !== 'string' || raw.includes('\0'))) {
throw failure(400, 'bad_path', 'The folder must be a path.');
}
Expand All @@ -113,6 +140,7 @@ export function createFolderBrowser({ platform = process.platform, home = os.hom
const dir = await nearestFolder(wanted);
const [folders, drives, files] = await Promise.all([entries(dir), roots(), extension ? filesIn(dir, extension) : null]);
const parent = paths.dirname(dir);
const [found, steam] = pieces ? await Promise.all([piecesIn(dir, folders, pieces), workshop ? workshop() : null]) : [null, null];
return {
path: dir,
parent: parent === dir ? null : parent,
Expand All @@ -123,6 +151,7 @@ export function createFolderBrowser({ platform = process.platform, home = os.hom
truncated: folders.length > limit,
note: dir === wanted ? null : wanted,
...(files ? { files: files.slice(0, limit) } : {}),
...(found ? { pieces: found, workshop: steam } : {}),
};
},
};
Expand Down
2 changes: 2 additions & 0 deletions src/manager/main.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import { GitHub } from './github.mjs';
import { createManagerServer } from './server.mjs';
import { YardArt } from './yard-art.mjs';
import { YardWorlds } from './yard-worlds.mjs';
import { YardPieces } from './yard-pieces.mjs';
import { loadPty } from './pty.mjs';
import { createNotesStore } from './notes.mjs';
import { createCardNames } from './card-names.mjs';
Expand Down Expand Up @@ -188,6 +189,7 @@ export async function startManager({ port = resolvePort(), host = DEFAULT_HOST,
launcher: launcherPath(),
yardArt: new YardArt({ webDir, dir: paths.yardArt }),
yardWorlds: new YardWorlds({ dir: paths.yardWorlds }),
yardPieces: new YardPieces({ dir: paths.yardPieces }),
mcpConnect,
onShutdownRequest: ({ restart = false } = {}) => shutdown('requested via API', { restart }).then((code) => process.exit(code)),
});
Expand Down
Loading
Loading