Skip to content
Merged

fix #94

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
9 changes: 9 additions & 0 deletions .changeset/odd-flies-ask.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
"shellular": patch
---

Cache each agent's session config (models, modes, slash commands) in SQLite so the app can render a populated composer for a chat that has no session yet.

ACP only exposes config once a session exists, but the app now creates one lazily on first send. The last-known config per agent is persisted and returned with the agent list, then refreshed whenever an agent advertises new values — on `session/load`, on `config_option_update`/`available_commands_update` notifications, and on `session/set_config_option` responses.

Adds an internal `m-id` command that prints the machine ID.
136 changes: 68 additions & 68 deletions cli/package.json
Original file line number Diff line number Diff line change
@@ -1,70 +1,70 @@
{
"name": "shellular",
"version": "0.0.49",
"description": "Shellular CLI — host your dev environment for remote access from your phone.",
"main": "dist/main.js",
"scripts": {
"dev": "cross-env SHELLULAR_DEV=true tsx --import ./scripts/register-sql-loader.mjs --watch src/main.ts",
"start": "tsx --import ./scripts/register-sql-loader.mjs src/main.ts",
"build": "tsc && tsup",
"schema": "tsx scripts/generate-schema.ts",
"prepublishOnly": "pnpm run schema && pnpm run build",
"ci:publish": "pnpm publish --access public",
"try-run": "pnpm run build && node dist/main.js"
},
"publishConfig": {
"access": "public"
},
"bin": {
"shellular": "dist/main.js"
},
"keywords": [
"shellular",
"remote",
"development",
"cli"
],
"license": "AGPL-3.0-only",
"type": "module",
"files": [
"dist",
"schema.sql",
"readme.md",
"LICENSE"
],
"dependencies": {
"@agentclientprotocol/sdk": "^1.2.1",
"@ff-labs/fff-node": "^0.6.4",
"@inquirer/prompts": "^8.4.2",
"@opencode-ai/sdk": "1.14.41",
"@shellular/protocol": "workspace:*",
"@xterm/addon-serialize": "^0.14.0",
"@xterm/headless": "^6.0.0",
"better-sqlite3": "^13.0.1",
"chalk": "^5.6.2",
"commander": "^14.0.3",
"libsodium-wrappers": "^0.8.3",
"nanoid": "^5.1.7",
"node-machine-id": "^1.1.12",
"node-pty": "^1.2.0-beta.12",
"pm2": "^7.0.3",
"qrcode-terminal": "^0.12.0",
"semver": "^7.7.4",
"ws": "^8.21.1",
"zod": "^4.3.6"
},
"devDependencies": {
"@types/better-sqlite3": "^7.6.13",
"@types/node": "^22.19.17",
"@types/qrcode-terminal": "^0.12.2",
"@types/semver": "^7.7.1",
"@types/ws": "^8.18.1",
"cross-env": "^10.1.0",
"tsup": "^8.5.1",
"tsx": "^4.23.1",
"typescript": "^6.0.3"
},
"optionalDependencies": {
"bufferutil": "^4.1.0"
}
"name": "shellular",
"version": "0.0.49",
"description": "Shellular CLI — host your dev environment for remote access from your phone.",
"main": "dist/main.js",
"scripts": {
"dev": "cross-env SHELLULAR_DEV=true tsx --import ./scripts/register-sql-loader.mjs --watch src/main.ts",
"start": "tsx --import ./scripts/register-sql-loader.mjs src/main.ts",
"build": "pnpm run schema && tsc && tsup",
"schema": "tsx scripts/generate-schema.ts",
"prepublishOnly": "pnpm run build",
"ci:publish": "pnpm publish --access public",
"try-run": "pnpm run build && node dist/main.js"
},
"publishConfig": {
"access": "public"
},
"bin": {
"shellular": "dist/main.js"
},
"keywords": [
"shellular",
"remote",
"development",
"cli"
],
"license": "AGPL-3.0-only",
"type": "module",
"files": [
"dist",
"schema.sql",
"readme.md",
"LICENSE"
],
"dependencies": {
"@agentclientprotocol/sdk": "^1.2.1",
"@ff-labs/fff-node": "^0.6.4",
"@inquirer/prompts": "^8.4.2",
"@opencode-ai/sdk": "1.14.41",
"@shellular/protocol": "workspace:*",
"@xterm/addon-serialize": "^0.14.0",
"@xterm/headless": "^6.0.0",
"better-sqlite3": "^13.0.1",
"chalk": "^5.6.2",
"commander": "^14.0.3",
"libsodium-wrappers": "^0.8.3",
"nanoid": "^5.1.7",
"node-machine-id": "^1.1.12",
"node-pty": "^1.2.0-beta.12",
"pm2": "^7.0.3",
"qrcode-terminal": "^0.12.0",
"semver": "^7.7.4",
"ws": "^8.21.1",
"zod": "^4.3.6"
},
"devDependencies": {
"@types/better-sqlite3": "^7.6.13",
"@types/node": "^22.19.17",
"@types/qrcode-terminal": "^0.12.2",
"@types/semver": "^7.7.1",
"@types/ws": "^8.18.1",
"cross-env": "^10.1.0",
"tsup": "^8.5.1",
"tsx": "^4.23.1",
"typescript": "^6.0.3"
},
"optionalDependencies": {
"bufferutil": "^4.1.0"
}
}
13 changes: 12 additions & 1 deletion cli/schema.sql
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,19 @@
-- Current database schema, produced by replaying src/db/sql/*.sql.
-- Regenerate with `pnpm run schema`.

PRAGMA user_version = 1;
PRAGMA user_version = 2;

CREATE TABLE agent_session_config_cache (
agent_id TEXT NOT NULL PRIMARY KEY,
config_json TEXT NOT NULL,
commands_json TEXT NOT NULL,
modes_json TEXT,
-- Agent version this was captured from. Slash commands and model lists move
-- between releases, so a row from a different version is discarded rather
-- than offering the user a model that no longer exists.
agent_version TEXT,
updated_at INTEGER NOT NULL
);
CREATE TABLE ai_messages (
agent_id TEXT NOT NULL,
session_id TEXT NOT NULL,
Expand Down
98 changes: 92 additions & 6 deletions cli/src/agents/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,10 @@ import { Hermes } from "./hermes";
import { NotifyBridge, type NotifyEvent } from "./notify-bridge";
import { OpenCode } from "./opencode";
import { Pi } from "./pi";
import {
readCachedSessionConfig,
writeCachedSessionConfig,
} from "./session-config-cache";
import { type ExternalSessionUpdate, SessionWatcher } from "./session-watcher";
import {
normalizeCustomAgentInput,
Expand Down Expand Up @@ -538,7 +542,14 @@ export class AgentsManager {
this.isAgentEnabled(descriptor.id) &&
installed.get(descriptor.id) === true,
)
.map((descriptor) => this.getManagedAgentInfo(descriptor, installed));
.map((descriptor) => {
const info = this.getManagedAgentInfo(descriptor, installed);
// Last-known session config, so a draft chat (which has no session
// yet) can render real modes/models/slash commands instead of an
// empty toolbar. Advisory only — the live session overwrites it.
const cached = readCachedSessionConfig(descriptor.id, info.version);
return cached ? { ...info, sessionConfig: cached } : info;
});
}

async listManagedAgents() {
Expand Down Expand Up @@ -839,6 +850,17 @@ export class AgentsManager {
const sessionId = result.session.id ?? result.response.sessionId;
this.sessionRuntimes.set(this.sessionKey(agentId, sessionId), agent);
this.sessionAgents.set(sessionId, agentId);
// Commands are deliberately not cached here. `result.availableCommands`
// reads the connection's per-session tracking map, which is keyed by a
// session id created moments ago and populated only by an incoming
// `available_commands_update` — so for a brand-new session it is always
// empty. The real list is cached when that notification arrives (see
// `cacheSessionConfigFromUpdate`).
writeCachedSessionConfig(agentId, {
configOptions: result.response.configOptions,
modes: result.response.modes,
version: agent.getInfo().version,
});
return result;
}

Expand Down Expand Up @@ -1014,16 +1036,26 @@ export class AgentsManager {
configOptions: result.response.configOptions ?? undefined,
};
const runtimeState = this.rememberSessionRuntimeMetadata(agentId, session);
const loadedCommands = latestAvailableCommands(
result.updates,
agent.getAvailableCommands(sessionId),
);
// Unlike session/new, commands are real here: session/load replays the
// session's notifications before its response resolves, so `result.updates`
// genuinely carries an `available_commands_update` for this session.
writeCachedSessionConfig(agentId, {
configOptions: result.response.configOptions,
availableCommands: loadedCommands,
modes: result.response.modes,
version: agent.getInfo().version,
});
const snapshot = this.setSessionSnapshot(agentId, sessionId, {
backend: agentId,
session,
state: {
configOptions: result.response.configOptions ?? undefined,
modes: result.response.modes,
availableCommands: latestAvailableCommands(
result.updates,
agent.getAvailableCommands(sessionId),
),
availableCommands: loadedCommands,
},
runtimeState,
messages: result.messages,
Expand Down Expand Up @@ -1461,11 +1493,20 @@ export class AgentsManager {
value: string | boolean,
) {
const agent = await this.connectSessionAgent(clientId, agentId, sessionId);
return agent.setSessionConfigOption({
const response = await agent.setSessionConfigOption({
sessionId,
configId,
...(typeof value === "boolean" ? { type: "boolean", value } : { value }),
});
// The updated list comes back on this response rather than as a
// session/update, so it would otherwise bypass the notification-based
// caching entirely — leaving a draft chat showing the option list from
// before the user changed their model.
writeCachedSessionConfig(agentId, {
configOptions: response.configOptions,
version: agent.getInfo().version,
});
return response;
}

async setSessionMode(
Expand Down Expand Up @@ -2536,6 +2577,14 @@ export class AgentsManager {
this.runtimeIds.set(agent, `runtime_${++this.nextRuntimeId}`);
agent.onSessionUpdate((notification) => {
const backend = descriptor.id;
// Cache before the client fan-out below: config and slash commands keep
// arriving after session/new and session/load resolve (agents send
// `available_commands_update` asynchronously, and the user can switch
// model or mode mid-session), so those two responses alone capture a
// stale picture. This also has to run ahead of the `clientId` guard —
// an update with no attached client is still the newest truth about
// what this agent offers.
cacheSessionConfigFromUpdate(backend, notification, agent);
const key = this.sessionKey(descriptor.id, notification.sessionId);
const clientId =
this.sessionClientIds.get(key) ??
Expand Down Expand Up @@ -2973,6 +3022,43 @@ function latestAvailableCommands(
return tracked;
}

/**
* Persist config/commands carried by a live `session/update`, so a draft chat's
* toolbar reflects what the agent offers *now* rather than what it offered when
* its last session was created.
*
* Only the two updates that carry a full replacement list are cacheable.
* `current_mode_update` is deliberately ignored: it names the newly-selected
* mode id without restating the option list, and the selection belongs to that
* one session — a later draft chat starts from the agent's own default, not from
* whatever some earlier session happened to be switched to.
*/
function cacheSessionConfigFromUpdate(
agentId: AgentId,
notification: acp.SessionNotification,
agent: ACP,
) {
const update = notification.update as {
sessionUpdate?: unknown;
configOptions?: unknown;
availableCommands?: unknown;
};
if (
update.sessionUpdate !== "config_option_update" &&
update.sessionUpdate !== "available_commands_update"
) {
return;
}
// A partial write here is safe: writeCachedSessionConfig drops empty fields
// and merges the rest onto the stored row, so a commands-only update keeps
// the previously cached config options.
writeCachedSessionConfig(agentId, {
configOptions: update.configOptions,
availableCommands: update.availableCommands,
version: agent.getInfo().version,
});
}

function sessionStatusEvent(
notification: acp.SessionNotification,
): AiEvent | null {
Expand Down
Loading
Loading