@oddessentials/agent-guild 0.1.1 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -11,8 +11,9 @@ import { EventEmitter } from 'node:events';
11
11
  import { fileURLToPath } from 'node:url';
12
12
  import { resolveCommand, resolveAllCommands, pathKey, buildSpawnSpec, runSpec } from './command-resolver.mjs';
13
13
  import { compareVersions, probeVersion, fetchManifest, latestVersion, DEFAULT_NPM_REGISTRY } from './versions.mjs';
14
- import { CHANNEL_LABELS, classifyInstall, formatCommand, helpDescribes, knownLaunchers, listInstallations, platformDependency } from './install-channels.mjs';
14
+ import { CHANNEL_LABELS, classifyInstall, expandHome, formatCommand, helpDescribes, knownLaunchers, listInstallations, platformDependency } from './install-channels.mjs';
15
15
  import { weavePaths } from './shell-env.mjs';
16
+ import { paths } from './config.mjs';
16
17
 
17
18
  const here = path.dirname(fileURLToPath(import.meta.url));
18
19
  export const DEFAULTS_FILE = path.resolve(here, '../../config/providers.default.json');
@@ -86,6 +87,50 @@ function normalizeChannels(raw) {
86
87
  return out;
87
88
  }
88
89
 
90
+ function normalizeHooks(raw) {
91
+ if (!raw || typeof raw !== 'object' || typeof raw.path !== 'string' || typeof raw.example !== 'string') return null;
92
+ const parts = raw.path.split(/[\\/]+/);
93
+ if (parts.some((part) => part === '' || part === '.' || part === '..') || !/^[A-Za-z0-9._-]+$/.test(raw.example)) return null;
94
+ return { path: parts.join('/'), example: raw.example };
95
+ }
96
+
97
+ const DEFAULT_ACCOUNT = 'default';
98
+
99
+ function normalizeAccounts(raw, id, homeVar, warnings) {
100
+ const accounts = [{ id: DEFAULT_ACCOUNT, label: 'Default', dir: null }];
101
+ if (raw === undefined || raw === null) return accounts;
102
+ if (!Array.isArray(raw)) {
103
+ warnings.push(`provider "${id}": ignored accounts; it must be an array`);
104
+ return accounts;
105
+ }
106
+ if (!homeVar && raw.length > 0) {
107
+ warnings.push(`provider "${id}": ignored accounts; the provider has no homeVar`);
108
+ return accounts;
109
+ }
110
+ for (const entry of raw) {
111
+ const item = typeof entry === 'string' ? { id: entry } : entry;
112
+ const accountId = item && typeof item === 'object' ? String(item.id ?? '') : '';
113
+ if (!ID_RE.test(accountId)) {
114
+ warnings.push(`provider "${id}": skipped account with invalid id ${JSON.stringify(item?.id ?? entry)}`);
115
+ continue;
116
+ }
117
+ const label = typeof item.label === 'string' && item.label.trim() ? item.label.trim().slice(0, 40) : accountId.charAt(0).toUpperCase() + accountId.slice(1);
118
+ const dir = typeof item.dir === 'string' && item.dir.trim() ? item.dir.trim() : null;
119
+ const existing = accounts.find((a) => a.id === accountId);
120
+ if (existing) {
121
+ if (accountId !== DEFAULT_ACCOUNT) {
122
+ warnings.push(`provider "${id}": skipped duplicate account "${accountId}"`);
123
+ continue;
124
+ }
125
+ existing.label = label;
126
+ if (dir) warnings.push(`provider "${id}": ignored dir of the default account; it uses the tool's own home folder`);
127
+ continue;
128
+ }
129
+ accounts.push({ id: accountId, label, dir });
130
+ }
131
+ return accounts;
132
+ }
133
+
89
134
  function httpsUrl(value, field, id, warnings) {
90
135
  if (value === undefined || value === null || value === '') return null;
91
136
  try {
@@ -99,6 +144,7 @@ function httpsUrl(value, field, id, warnings) {
99
144
  function normalize(raw, platform, warnings) {
100
145
  const merged = { ...raw, ...(raw[platform] || {}) };
101
146
  for (const key of PLATFORM_KEYS) delete merged[key];
147
+ const homeVar = typeof merged.homeVar === 'string' && /^[A-Za-z_][A-Za-z0-9_]*$/.test(merged.homeVar) ? merged.homeVar : null;
102
148
  return {
103
149
  id: merged.id,
104
150
  vendor: String(merged.vendor || merged.id),
@@ -111,6 +157,10 @@ function normalize(raw, platform, warnings) {
111
157
  args: Array.isArray(merged.args) ? merged.args.map(String) : [],
112
158
  resumeArgs: Array.isArray(merged.resumeArgs) ? merged.resumeArgs.map(String) : [],
113
159
  env: normalizeEnv(merged.env),
160
+ homeVar,
161
+ accountEnv: normalizeEnv(merged.accountEnv),
162
+ hooks: normalizeHooks(merged.hooks),
163
+ accounts: normalizeAccounts(merged.accounts, merged.id, homeVar, warnings),
114
164
  color: String(merged.color || '#64748B'),
115
165
  monogram: String(merged.monogram || String(merged.vendor || merged.id).charAt(0)).slice(0, 2),
116
166
  icon: merged.icon ? String(merged.icon) : null,
@@ -172,10 +222,12 @@ export class ProviderRegistry extends EventEmitter {
172
222
  * @param {string} [opts.registryUrl] npm registry for lookups and installs; default: npm's own configuration
173
223
  * @param {boolean} [opts.checkUpdates] false skips registry lookups entirely
174
224
  * @param {Function} [opts.fetchImpl]
225
+ * @param {string} [opts.accountsDir] where accounts without a dir get their home folders
175
226
  */
176
- constructor({ userFile, env, platform = process.platform, iconDir, registryUrl = null, checkUpdates = true, fetchImpl, pathReader = null } = {}) {
227
+ constructor({ userFile, env, platform = process.platform, iconDir, registryUrl = null, checkUpdates = true, fetchImpl, pathReader = null, accountsDir = paths.accounts } = {}) {
177
228
  super();
178
229
  this.userFile = userFile;
230
+ this.accountsDir = accountsDir;
179
231
  this.env = env || process.env;
180
232
  this.platform = platform;
181
233
  this.iconDir = iconDir;
@@ -184,6 +236,7 @@ export class ProviderRegistry extends EventEmitter {
184
236
  this.fetchImpl = fetchImpl;
185
237
  this.pathReader = pathReader;
186
238
  this._pathReadAt = 0;
239
+ this._pathPending = null;
187
240
  this._installs = new Map();
188
241
  this.versions = new Map();
189
242
  this._refreshing = null;
@@ -209,6 +262,8 @@ export class ProviderRegistry extends EventEmitter {
209
262
  npmRegistryUrl() {
210
263
  if (this.registryUrl) return Promise.resolve(this.registryUrl);
211
264
  this._npmRegistry ??= (async () => {
265
+ // PATH discovery in flight may change which npm is found; wait for it.
266
+ await this._pathPending;
212
267
  const npm = this.resolveNpm();
213
268
  if (!npm) return DEFAULT_NPM_REGISTRY;
214
269
  try {
@@ -236,11 +291,18 @@ export class ProviderRegistry extends EventEmitter {
236
291
  return pending;
237
292
  }
238
293
 
239
- async refreshPath({ force = false } = {}) {
240
- if (!this.pathReader) return false;
294
+ /** Resolves to true when the PATH changed. A concurrent caller waits for the read already in flight. */
295
+ refreshPath({ force = false } = {}) {
296
+ if (!this.pathReader) return Promise.resolve(false);
241
297
  const now = Date.now();
242
- if (!force && now - this._pathReadAt < PATH_REFRESH_MS) return false;
298
+ if (!force && now - this._pathReadAt < PATH_REFRESH_MS) return this._pathPending ?? Promise.resolve(false);
243
299
  this._pathReadAt = now;
300
+ const pending = this._readPath().finally(() => { if (this._pathPending === pending) this._pathPending = null; });
301
+ this._pathPending = pending;
302
+ return pending;
303
+ }
304
+
305
+ async _readPath() {
244
306
  const discovered = await Promise.resolve().then(() => this.pathReader()).catch(() => null);
245
307
  if (!discovered) return false;
246
308
  const key = pathKey(this.env, this.platform);
@@ -462,6 +524,27 @@ export class ProviderRegistry extends EventEmitter {
462
524
  return this.providers.find((p) => p.id === id) || null;
463
525
  }
464
526
 
527
+ accountFor(provider, account) {
528
+ if (account.id === DEFAULT_ACCOUNT || !provider.homeVar) return { id: account.id, label: account.label, dir: null, env: {} };
529
+ const p = this.platform === 'win32' ? path.win32 : path.posix;
530
+ const own = account.dir ? expandHome(account.dir, this.env, this.platform) : null;
531
+ const dir = own && p.isAbsolute(own) ? p.normalize(own) : p.join(this.accountsDir, provider.id, own || account.id);
532
+ const env = { [provider.homeVar]: dir };
533
+ for (const [key, value] of Object.entries(provider.accountEnv)) env[key] = value.replaceAll('{dir}', dir);
534
+ return { id: account.id, label: account.label, dir, env };
535
+ }
536
+
537
+ accountsFor(provider) {
538
+ return provider.accounts.map((account) => this.accountFor(provider, account));
539
+ }
540
+
541
+ account(provider, id = null) {
542
+ const wanted = id === null || id === undefined || id === '' ? DEFAULT_ACCOUNT : String(id);
543
+ const found = provider.accounts.find((a) => a.id === wanted);
544
+ if (!found) throw refusal(404, 'unknown_account', `${provider.tool} has no account "${wanted}"`);
545
+ return this.accountFor(provider, found);
546
+ }
547
+
465
548
  commandFor(provider) {
466
549
  return provider.command === '@shell' ? defaultShell(this.env, this.platform) : provider.command;
467
550
  }
@@ -529,6 +612,7 @@ export class ProviderRegistry extends EventEmitter {
529
612
  warnings: this.installWarnings(provider, installs),
530
613
  npmNote: provider.npmNote,
531
614
  usageSource: provider.usage === null ? null : typeof provider.usage === 'string' ? provider.usage : 'command',
615
+ accounts: provider.accounts.map(({ id, label }) => ({ id, label })),
532
616
  modelPattern: provider.modelPattern,
533
617
  color: provider.color,
534
618
  monogram: provider.monogram,
@@ -0,0 +1,205 @@
1
+ // The manager's own version against the npm registry, and the npm session
2
+ // that upgrades it. The running process keeps its code after an upgrade:
3
+ // the new version is used once the manager is restarted.
4
+
5
+ import fs from 'node:fs';
6
+ import { EventEmitter } from 'node:events';
7
+ import { compareVersions, fetchManifest } from './versions.mjs';
8
+ import { formatCommand } from './install-channels.mjs';
9
+ import { buildSpawnSpec } from './command-resolver.mjs';
10
+
11
+ const CHECK_TTL_MS = 60 * 60 * 1000;
12
+ const FAILED_CHECK_TTL_MS = 5 * 60 * 1000;
13
+
14
+ /** Shown as the provider of the upgrade session. */
15
+ export const SELF_PROVIDER = Object.freeze({
16
+ id: 'agent-guild',
17
+ vendor: 'Agent Guild',
18
+ tool: 'Agent Guild',
19
+ color: '#5B5BD6',
20
+ monogram: 'AG',
21
+ iconUrl: null,
22
+ modelPattern: null,
23
+ env: {},
24
+ });
25
+
26
+ /** True for a build that is not a published release, such as a git checkout. */
27
+ export function isDevelopmentBuild(version) {
28
+ return !version || /^0\.0\.0(?:-|$)/.test(String(version));
29
+ }
30
+
31
+ function refusal(status, code, message) {
32
+ return Object.assign(new Error(message), { status, code });
33
+ }
34
+
35
+ export class SelfUpdate extends EventEmitter {
36
+ /**
37
+ * @param {object} opts
38
+ * @param {string} opts.pkg the manager's npm package name
39
+ * @param {string} opts.version the running version
40
+ * @param {string|null} [opts.packageFile] the package.json the manager runs from; read again after an install
41
+ * @param {import('./providers.mjs').ProviderRegistry} opts.registry for the registry URL, npm, and fetch
42
+ */
43
+ constructor({ pkg, version, packageFile = null, registry }) {
44
+ super();
45
+ this.pkg = pkg;
46
+ this.version = version;
47
+ this.packageFile = packageFile;
48
+ this.registry = registry;
49
+ this.latest = null;
50
+ this.error = null;
51
+ this.checkedAt = 0;
52
+ this.lastInstall = null;
53
+ /** True from the start of an upgrade session until its npm process has exited. */
54
+ this.installing = false;
55
+ /** The on-disk version a failed upgrade left behind, not to be trusted until an upgrade completes. */
56
+ this.suspectVersion = null;
57
+ this._refreshing = null;
58
+ }
59
+
60
+ /** Ask the registry for the latest release, hourly unless `force`. Emits "updated" on a change. */
61
+ refresh({ force = false } = {}) {
62
+ const run = () => this._refresh(force);
63
+ const pending = this._refreshing ? this._refreshing.then(run, run) : run();
64
+ this._refreshing = pending;
65
+ pending.finally(() => { if (this._refreshing === pending) this._refreshing = null; }).catch(() => {});
66
+ return pending;
67
+ }
68
+
69
+ async _refresh(force) {
70
+ if (!this.registry.checkUpdates || isDevelopmentBuild(this.version)) return;
71
+ const now = Date.now();
72
+ const ttl = this.error ? FAILED_CHECK_TTL_MS : CHECK_TTL_MS;
73
+ if (!force && now - this.checkedAt < ttl) return;
74
+ this.checkedAt = now;
75
+ const registryUrl = await this.registry.npmRegistryUrl();
76
+ const { manifest, error } = await fetchManifest(this.pkg, 'latest', { registryUrl, fetchImpl: this.registry.fetchImpl });
77
+ // A failed check keeps the release already known; it is still published.
78
+ const latest = manifest?.version ?? this.latest;
79
+ const changed = latest !== this.latest || error !== this.error;
80
+ this.latest = latest;
81
+ this.error = error;
82
+ if (changed) this.emit('updated');
83
+ }
84
+
85
+ /** Forget an upgrade's outcome once a newer release or a different install supersedes it. */
86
+ _pruneLastInstall(installed) {
87
+ const last = this.lastInstall;
88
+ if (last && (last.version !== this.latest || last.installedVersion !== installed)) this.lastInstall = null;
89
+ }
90
+
91
+ /** The version of the package files on disk, or null when unreadable (for example mid-install). */
92
+ installedVersion() {
93
+ if (!this.packageFile) return null;
94
+ try {
95
+ const version = JSON.parse(fs.readFileSync(this.packageFile, 'utf8')).version;
96
+ return typeof version === 'string' ? version : null;
97
+ } catch {
98
+ return null;
99
+ }
100
+ }
101
+
102
+ /**
103
+ * True while the files on disk are what an upgrade that did not finish
104
+ * left behind: npm may have replaced package.json before it was stopped.
105
+ * Kept apart from `lastInstall`, which a newer release supersedes; the
106
+ * files stay suspect until an upgrade completes or they change again.
107
+ */
108
+ _diskSuspect(installed) {
109
+ return this.suspectVersion !== null && installed === this.suspectVersion;
110
+ }
111
+
112
+ /** A version installed on disk that the running manager does not use yet, or null. */
113
+ pendingVersion(installed = this.installedVersion()) {
114
+ if (this._diskSuspect(installed)) return null;
115
+ return installed && installed !== this.version && compareVersions(installed, this.version) > 0 ? installed : null;
116
+ }
117
+
118
+ /** True when the latest release is newer than the running manager and not yet, or not reliably, on disk. */
119
+ available(installed = this.installedVersion()) {
120
+ if (!this.latest || compareVersions(this.latest, this.version) <= 0) return false;
121
+ return this._diskSuspect(installed) || compareVersions(this.latest, installed || this.version) > 0;
122
+ }
123
+
124
+ args() {
125
+ return this.registry.npmArgs({ args: ['install', '-g'], package: this.pkg }, this.latest);
126
+ }
127
+
128
+ /** The npm command that performs the upgrade, or null without npm on PATH or a known release. */
129
+ command() {
130
+ const npm = this.registry.resolveNpm();
131
+ return npm && this.latest ? formatCommand(npm, this.args()) : null;
132
+ }
133
+
134
+ /** Public description, sent in `/info`, `hello` and `manager.upgrade`. */
135
+ describe() {
136
+ // While npm runs, the files on disk are in flux: package.json may already
137
+ // be new while dependencies are still being written, so nothing is
138
+ // offered or announced until the process has exited.
139
+ if (this.installing) {
140
+ return {
141
+ version: this.version, latestVersion: this.latest, available: false, command: null, guidance: null,
142
+ pendingVersion: null, installing: true, lastInstall: null,
143
+ };
144
+ }
145
+ const installed = this.installedVersion();
146
+ this._pruneLastInstall(installed);
147
+ const available = this.available(installed);
148
+ const command = available ? this.command() : null;
149
+ return {
150
+ version: this.version,
151
+ latestVersion: this.latest,
152
+ available,
153
+ command,
154
+ guidance: available && !command ? `npm was not found on PATH. Install Node.js from https://nodejs.org, then run: npm install -g ${this.pkg}@${this.latest}` : null,
155
+ pendingVersion: this.pendingVersion(installed),
156
+ installing: false,
157
+ lastInstall: this.lastInstall,
158
+ };
159
+ }
160
+
161
+ /** Spawn spec for the upgrade session and the version it installs, or throws with a user-facing message. */
162
+ async spec() {
163
+ if (this.installing) throw refusal(409, 'upgrade_in_progress', 'Agent Guild is already being upgraded');
164
+ if (isDevelopmentBuild(this.version)) {
165
+ throw refusal(400, 'not_updatable', `This is a development build of Agent Guild (${this.version}); it is not upgraded from the registry.`);
166
+ }
167
+ if (!this.registry.checkUpdates) {
168
+ throw refusal(400, 'not_updatable', 'Version checks are off (AGENT_GUILD_NO_UPDATE_CHECK), so Agent Guild cannot upgrade itself.');
169
+ }
170
+ await this.refresh();
171
+ const installed = this.installedVersion();
172
+ this._pruneLastInstall(installed);
173
+ if (!this.available(installed)) {
174
+ throw refusal(400, 'not_updatable', this.latest
175
+ ? `Agent Guild ${this.latest} is the latest release${this.pendingVersion(installed) ? ' and is installed; restart the manager to use it' : ''}.`
176
+ : `Could not read the latest Agent Guild release${this.error ? `: ${this.error}` : ''}. Nothing was changed.`);
177
+ }
178
+ const npm = this.registry.resolveNpm();
179
+ if (!npm) {
180
+ throw refusal(409, 'npm_unavailable', 'npm was not found on PATH. Install Node.js from https://nodejs.org and restart the session manager.');
181
+ }
182
+ // buildSpawnSpec wraps npm.cmd in cmd.exe on Windows, which a PTY needs.
183
+ return { spec: buildSpawnSpec(npm, this.args(), this.registry.env, this.registry.platform), version: this.latest };
184
+ }
185
+
186
+ /** An upgrade session started; hold the lock until finishInstall. Emits "updated". */
187
+ beginInstall() {
188
+ this.installing = true;
189
+ this.emit('updated');
190
+ }
191
+
192
+ /** Record how the upgrade session ended, once its process has exited. The files on disk say whether the running copy was replaced. */
193
+ finishInstall({ exitCode = null, version = this.latest } = {}) {
194
+ this.installing = false;
195
+ const installed = this.installedVersion();
196
+ let outcome;
197
+ if (exitCode !== 0) outcome = 'failed';
198
+ else if (installed && version && compareVersions(installed, version) >= 0) outcome = 'installed';
199
+ else outcome = 'unchanged';
200
+ if (outcome === 'failed') this.suspectVersion = installed;
201
+ else if (outcome === 'installed') this.suspectVersion = null;
202
+ this.lastInstall = { outcome, exitCode, version, installedVersion: installed, at: Date.now() };
203
+ this.emit('updated');
204
+ }
205
+ }
@@ -100,14 +100,17 @@ export function createManagerServer({
100
100
  manager,
101
101
  registry,
102
102
  usage,
103
+ modelStats,
103
104
  token,
104
105
  host = '127.0.0.1',
105
106
  port = 0,
106
107
  webDir,
107
108
  version = '0.0.0',
109
+ selfUpdate = null,
108
110
  extraOrigins = [],
109
111
  onShutdownRequest = () => {},
110
112
  }) {
113
+ const upgradeInfo = () => (selfUpdate ? selfUpdate.describe() : null);
111
114
  const startedAt = new Date().toISOString();
112
115
  const vendor = vendorFiles();
113
116
  let boundPort = port;
@@ -197,6 +200,7 @@ export function createManagerServer({
197
200
  requireAuth(req, url);
198
201
 
199
202
  if (route === '/info' && method === 'GET') {
203
+ selfUpdate?.refresh().catch(() => {});
200
204
  return sendJson(res, 200, {
201
205
  name: 'agent-guild',
202
206
  version,
@@ -204,8 +208,13 @@ export function createManagerServer({
204
208
  platform: process.platform,
205
209
  startedAt,
206
210
  warnings: registry.warnings,
211
+ upgrade: upgradeInfo(),
207
212
  });
208
213
  }
214
+ if (route === '/upgrade' && method === 'POST') {
215
+ const session = await manager.upgrade();
216
+ return sendJson(res, 201, { session: session.toJSON() });
217
+ }
209
218
  if (route === '/providers' && method === 'GET') {
210
219
  registry.refreshVersions().catch(() => {});
211
220
  return sendJson(res, 200, { providers: registry.list() });
@@ -218,6 +227,9 @@ export function createManagerServer({
218
227
  if (route === '/usage' && method === 'GET') {
219
228
  return sendJson(res, 200, { usage: await usage.all() });
220
229
  }
230
+ if (route === '/model-stats' && method === 'GET') {
231
+ return sendJson(res, 200, await modelStats.snapshot(manager.list()));
232
+ }
221
233
  const installMatch = route.match(/^\/providers\/([a-z0-9][a-z0-9_-]{0,31})\/install$/);
222
234
  if (installMatch && method === 'POST') {
223
235
  const body = await readJsonBody(req);
@@ -329,10 +341,11 @@ export function createManagerServer({
329
341
  }
330
342
  manager.on('event', broadcast);
331
343
  registry.on('updated', () => broadcast({ type: 'providers.updated', providers: registry.list() }));
344
+ selfUpdate?.on('updated', () => broadcast({ type: 'manager.upgrade', upgrade: upgradeInfo() }));
332
345
 
333
346
  function handleEvents(ws) {
334
347
  eventClients.add(ws);
335
- safeSend(ws, { type: 'hello', version, sessions: manager.list() });
348
+ safeSend(ws, { type: 'hello', version, upgrade: upgradeInfo(), sessions: manager.list() });
336
349
  ws.on('close', () => eventClients.delete(ws));
337
350
  ws.on('message', () => { /* events socket is server -> client only */ });
338
351
  }
@@ -6,11 +6,14 @@ import fs from 'node:fs';
6
6
  import os from 'node:os';
7
7
  import path from 'node:path';
8
8
  import crypto from 'node:crypto';
9
- import { Session, newId, clampDimension } from './session.mjs';
9
+ import { fileURLToPath } from 'node:url';
10
+ import { Session, newId, clampDimension, cleanName } from './session.mjs';
10
11
  import { prependPath } from './report-shims.mjs';
11
12
  import { CHANNEL_LABELS } from './install-channels.mjs';
13
+ import { SELF_PROVIDER } from './self-update.mjs';
12
14
 
13
15
  export const MAX_SESSIONS = 32;
16
+ const examplesDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../examples');
14
17
 
15
18
  function httpError(status, message, code) {
16
19
  return Object.assign(new Error(message), { status, code });
@@ -46,14 +49,16 @@ export class SessionManager extends EventEmitter {
46
49
  * @param {() => string} opts.getApiUrl base URL handed to tools for reporting
47
50
  * @param {object} [opts.sessionDefaults] passed through to Session
48
51
  * @param {string|null} [opts.shimDir] folder with the agent-guild-report launchers, put first on PATH
52
+ * @param {import('./self-update.mjs').SelfUpdate|null} [opts.selfUpdate] the manager's own upgrade
49
53
  */
50
- constructor({ registry, baseEnv, getApiUrl, sessionDefaults = {}, shimDir = null }) {
54
+ constructor({ registry, baseEnv, getApiUrl, sessionDefaults = {}, shimDir = null, selfUpdate = null }) {
51
55
  super();
52
56
  this.registry = registry;
53
57
  this.baseEnv = baseEnv;
54
58
  this.getApiUrl = getApiUrl;
55
59
  this.sessionDefaults = sessionDefaults;
56
60
  this.shimDir = shimDir;
61
+ this.selfUpdate = selfUpdate;
57
62
  this.sessions = new Map();
58
63
  /** Removed sessions whose process has not exited yet. */
59
64
  this.exiting = new Set();
@@ -83,21 +88,44 @@ export class SessionManager extends EventEmitter {
83
88
  return dir;
84
89
  }
85
90
 
86
- create({ providerId, cwd, cols, rows, name, args, resume } = {}) {
91
+ create({ providerId, cwd, cols, rows, name, args, resume, account } = {}) {
87
92
  const provider = this.registry.get(String(providerId || ''));
88
93
  if (!provider) throw httpError(404, `unknown provider "${providerId}"`, 'unknown_provider');
89
94
  if (args !== undefined && (!Array.isArray(args) || args.some((a) => typeof a !== 'string'))) {
90
95
  throw httpError(400, 'args must be an array of strings', 'bad_args');
91
96
  }
97
+ if (account !== undefined && account !== null && typeof account !== 'string') throw httpError(400, 'account must be a string', 'bad_account');
92
98
  const resumeId = cleanResumeId(resume);
93
99
  const workDir = this.resolveCwd(cwd);
100
+ const signIn = this.registry.account(provider, account);
94
101
  const spawnSpec = this.registry.spawnSpec(provider, args || [], resumeId);
95
- const session = this._spawn({ provider, spawnSpec, cwd: workDir, cols, rows, name, resume: resumeId });
102
+ this.prepareAccount(provider, signIn);
103
+ const sessionName = cleanName(name) || (provider.accounts.length > 1 ? `${provider.tool} · ${signIn.label}` : null);
104
+ const session = this._spawn({ provider, spawnSpec, cwd: workDir, cols, rows, name: sessionName, resume: resumeId, account: signIn });
96
105
  const model = modelFromArgs([...provider.args, ...(args || [])]);
97
106
  if (model) session.setModel({ name: model }, 'args');
98
107
  return session;
99
108
  }
100
109
 
110
+ prepareAccount(provider, account) {
111
+ if (!account.dir) return;
112
+ try {
113
+ fs.mkdirSync(account.dir, { recursive: true, mode: 0o700 });
114
+ if (!provider.hooks) return;
115
+ const target = path.join(account.dir, ...provider.hooks.path.split('/'));
116
+ const example = path.join(examplesDir, provider.hooks.example);
117
+ if (fs.existsSync(target)) return;
118
+ if (!fs.existsSync(example)) {
119
+ console.warn(`[accounts] no hooks example ${example} for ${provider.tool}; ${target} was not written`);
120
+ return;
121
+ }
122
+ fs.mkdirSync(path.dirname(target), { recursive: true, mode: 0o700 });
123
+ fs.copyFileSync(example, target, fs.constants.COPYFILE_EXCL);
124
+ } catch (err) {
125
+ throw httpError(500, `could not prepare the ${account.label} account folder ${account.dir}: ${err.message}`, 'account_unavailable');
126
+ }
127
+ }
128
+
101
129
  /**
102
130
  * Refuses while sessions of that provider are running unless `force` is
103
131
  * set, because replacing a tool under a running process can break it.
@@ -140,6 +168,29 @@ export class SessionManager extends EventEmitter {
140
168
  return n;
141
169
  }
142
170
 
171
+ /**
172
+ * Upgrade the manager itself: a visible session running npm. Sessions
173
+ * keep running; the new version is used once the manager is restarted.
174
+ */
175
+ async upgrade() {
176
+ if (!this.selfUpdate) throw httpError(400, 'this manager cannot upgrade itself', 'not_updatable');
177
+ if (this.closing) throw httpError(503, 'the session manager is stopping', 'manager_stopping');
178
+ const inProgress = () => httpError(409, 'Agent Guild is already being upgraded', 'upgrade_in_progress');
179
+ if (this.selfUpdate.installing) throw inProgress();
180
+ const { spec, version } = await this.selfUpdate.spec();
181
+ if (this.selfUpdate.installing) throw inProgress();
182
+ const session = this._spawn({
183
+ provider: SELF_PROVIDER, description: SELF_PROVIDER, spawnSpec: spec,
184
+ cwd: os.homedir(), name: `Upgrade Agent Guild to ${version}`, task: 'upgrade',
185
+ });
186
+ // The lock is held until the npm process has exited, not until the
187
+ // session is removed: a removed session's process may still be writing
188
+ // the package, and two installers must not touch it at once.
189
+ this.selfUpdate.beginInstall();
190
+ session.exited.then(() => this.selfUpdate.finishInstall({ exitCode: session.exitCode, version }));
191
+ return session;
192
+ }
193
+
143
194
  runningFor(providerId) {
144
195
  let n = 0;
145
196
  for (const s of this.sessions.values()) if (s.status === 'running' && s.task === null && s.provider.id === providerId) n++;
@@ -153,18 +204,17 @@ export class SessionManager extends EventEmitter {
153
204
  return n;
154
205
  }
155
206
 
156
- _spawn({ provider, spawnSpec, cwd, cols, rows, name, resume = null, task = null, installKind = null }) {
207
+ _spawn({ provider, description = this.registry.describe(provider), spawnSpec, cwd, cols, rows, name, resume = null, task = null, installKind = null, account = null }) {
157
208
  if (this.closing) throw httpError(503, 'the session manager is stopping', 'manager_stopping');
158
209
  if (this.sessions.size >= MAX_SESSIONS) {
159
210
  throw httpError(429, `session limit reached (${MAX_SESSIONS}); remove finished sessions first`, 'too_many_sessions');
160
211
  }
161
- const description = this.registry.describe(provider);
162
212
  const id = newId();
163
213
  const reportToken = crypto.randomBytes(16).toString('hex');
164
214
 
165
215
  // The tool's hooks run `agent-guild-report` by name, so the launchers
166
216
  // go first on PATH, after any provider PATH override.
167
- const env = prependPath(mergeEnv([this.baseEnv, provider.env, {
217
+ const env = prependPath(mergeEnv([this.baseEnv, provider.env, account?.env, {
168
218
  TERM: 'xterm-256color',
169
219
  COLORTERM: 'truecolor',
170
220
  AGENT_GUILD_SESSION_ID: id,
@@ -194,6 +244,7 @@ export class SessionManager extends EventEmitter {
194
244
  reportToken,
195
245
  resume,
196
246
  task,
247
+ account: account ? { id: account.id, label: account.label } : null,
197
248
  });
198
249
  } catch (err) {
199
250
  throw httpError(500, `could not start ${provider.tool}: ${err.message}`, 'spawn_failed');
@@ -54,7 +54,8 @@ export class Session extends EventEmitter {
54
54
  * @param {number} opts.rows
55
55
  * @param {string} [opts.name]
56
56
  * @param {string|null} [opts.resume] id of the tool's own session being resumed
57
- * @param {string|null} [opts.task] "install" for a package install, else null
57
+ * @param {string|null} [opts.task] "install" for a package install, "upgrade" for the manager's own, else null
58
+ * @param {{id: string, label: string}|null} [opts.account] the tool sign-in the session runs under
58
59
  * @param {string} opts.reportToken
59
60
  * @param {number} [opts.scrollback]
60
61
  * @param {number} [opts.activityIdleMs]
@@ -68,6 +69,7 @@ export class Session extends EventEmitter {
68
69
  this.name = cleanName(opts.name) || opts.provider.tool;
69
70
  this.resume = opts.resume ?? null;
70
71
  this.task = opts.task ?? null;
72
+ this.account = opts.account ?? null;
71
73
  this.cwd = opts.cwd;
72
74
  this.cols = opts.cols;
73
75
  this.rows = opts.rows;
@@ -505,6 +507,7 @@ export class Session extends EventEmitter {
505
507
  cwd: this.cwd,
506
508
  resume: this.resume,
507
509
  task: this.task,
510
+ account: this.account,
508
511
  pid: this.pid,
509
512
  status: this.status,
510
513
  exitCode: this.exitCode,