@oddessentials/agent-guild 0.16.0 → 0.18.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.
package/README.md CHANGED
@@ -58,6 +58,13 @@ terminal: type instructions, answer prompts, watch output. Run as many as you
58
58
  like. A local session manager owns them, so you can close or reload the page
59
59
  and come back to the same screens.
60
60
 
61
+ **Pick your shell.** The Shell card offers the shells installed on your
62
+ computer and remembers your choice in this browser. On Windows it defaults
63
+ to PowerShell 7 when installed, then Windows PowerShell; Git Bash and Command
64
+ Prompt are also offered when found. On macOS and Linux it follows your login
65
+ shell. Pick the default chip to clear a saved choice and follow the default
66
+ again. The choice applies to new sessions.
67
+
61
68
  <img src="docs/images/terminal.webp" alt="An open Claude Code session with its helper agents shown in the header">
62
69
 
63
70
  **Agents and models at work.** Helper agents that a tool starts appear on its
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oddessentials/agent-guild",
3
- "version": "0.16.0",
3
+ "version": "0.18.0",
4
4
  "description": "Launch and watch AI coding-assistant terminal sessions from one local web page. A bundled session manager owns the terminals so the page can close and reconnect.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -13,6 +13,7 @@ import { resolveCommand, resolveAllCommands, pathKey, buildSpawnSpec, runSpec }
13
13
  import { compareVersions, probeVersion, fetchManifest, latestVersion, DEFAULT_NPM_REGISTRY } from './versions.mjs';
14
14
  import { CHANNEL_LABELS, classifyInstall, expandHome, formatCommand, homeRelative, knownLaunchers, listInstallations, platformDependency, updateHelpAccepted } from './install-channels.mjs';
15
15
  import { weavePaths } from './shell-env.mjs';
16
+ import { detectShells, fallbackShell } from './shells.mjs';
16
17
  import { RUNNER, encodePlan } from './uninstall.mjs';
17
18
  import { paths } from './config.mjs';
18
19
 
@@ -34,11 +35,6 @@ function refusal(status, code, message) {
34
35
  return Object.assign(new Error(message), { status, code });
35
36
  }
36
37
 
37
- export function defaultShell(env = process.env, platform = process.platform) {
38
- if (platform === 'win32') return 'powershell.exe';
39
- return env.SHELL || (platform === 'darwin' ? '/bin/zsh' : '/bin/bash');
40
- }
41
-
42
38
  function readJson(file) {
43
39
  return JSON.parse(fs.readFileSync(file, 'utf8'));
44
40
  }
@@ -259,6 +255,7 @@ export class ProviderRegistry extends EventEmitter {
259
255
  this._pathReadAt = 0;
260
256
  this._pathPending = null;
261
257
  this._installs = new Map();
258
+ this._shells = new Map();
262
259
  this.versions = new Map();
263
260
  this._refreshing = null;
264
261
  this._npmRegistry = null;
@@ -272,6 +269,7 @@ export class ProviderRegistry extends EventEmitter {
272
269
  this.warnings = warnings;
273
270
  this._npmRegistry = null;
274
271
  this._installs.clear();
272
+ this._shells.clear();
275
273
  for (const w of warnings) console.warn(`[providers] ${w}`);
276
274
  this.emit('updated');
277
275
  }
@@ -335,6 +333,7 @@ export class ProviderRegistry extends EventEmitter {
335
333
  if (next === (this.env[key] || '')) return false;
336
334
  this.env[key] = next;
337
335
  this._installs.clear();
336
+ this._shells.clear();
338
337
  return true;
339
338
  }
340
339
 
@@ -568,8 +567,32 @@ export class ProviderRegistry extends EventEmitter {
568
567
  return this.accountFor(provider, found);
569
568
  }
570
569
 
570
+ /** The shells the provider can start, or null when it is not the `@shell` provider. */
571
+ shellsFor(provider) {
572
+ if (provider.command !== '@shell') return null;
573
+ const cached = this._shells.get(provider.id);
574
+ if (cached && Date.now() - cached.at < INSTALLS_TTL_MS) return cached.found;
575
+ const found = detectShells({ ...this.env, ...provider.env }, this.platform);
576
+ this._shells.set(provider.id, { found, at: Date.now() });
577
+ return found;
578
+ }
579
+
580
+ /** The shell a session of the provider runs: the one named by `id`, else the default. Null for other providers. */
581
+ shellFor(provider, id = null) {
582
+ const found = this.shellsFor(provider);
583
+ if (!found) {
584
+ if (id == null) return null;
585
+ throw refusal(400, 'bad_shell', `${provider.tool} does not start a shell`);
586
+ }
587
+ const wanted = id ?? found.defaultId;
588
+ const shell = found.shells.find((s) => s.id === wanted) ?? null;
589
+ if (!shell && wanted !== null) throw refusal(409, 'shell_unavailable', `The shell "${wanted}" was not found on this computer`);
590
+ return shell;
591
+ }
592
+
571
593
  commandFor(provider) {
572
- return provider.command === '@shell' ? defaultShell(this.env, this.platform) : provider.command;
594
+ if (provider.command !== '@shell') return provider.command;
595
+ return this.shellFor(provider)?.path ?? fallbackShell(this.env, this.platform);
573
596
  }
574
597
 
575
598
  resolve(provider) {
@@ -597,6 +620,7 @@ export class ProviderRegistry extends EventEmitter {
597
620
  const channel = this.channelFor(provider, resolvedPath);
598
621
  const update = this.updateFor(provider, channel);
599
622
  const shown = (file) => homeRelative(file, { ...this.env, ...provider.env }, this.platform);
623
+ const shells = this.shellsFor(provider);
600
624
  const installs = this.installsFor(provider, resolvedPath).map((install) => {
601
625
  const active = install.resolvedPath === resolvedPath;
602
626
  const copy = active ? { version: installed, status: versions?.versionStatus ?? null } : versions?.copies?.[install.resolvedPath];
@@ -645,6 +669,8 @@ export class ProviderRegistry extends EventEmitter {
645
669
  reporting: provider.reporting,
646
670
  reportingEnabled: this.reportingEnabled?.(provider) ?? null,
647
671
  accounts: provider.accounts.map((account) => ({ id: account.id, label: account.label })),
672
+ shells: shells?.shells.map((shell) => ({ id: shell.id, label: shell.label, path: shell.path })) ?? null,
673
+ defaultShell: shells?.defaultId ?? null,
648
674
  modelPattern: provider.modelPattern,
649
675
  color: provider.color,
650
676
  monogram: provider.monogram,
@@ -664,8 +690,8 @@ export class ProviderRegistry extends EventEmitter {
664
690
  }
665
691
 
666
692
  /** Spawn spec for node-pty, or throws with a user-facing message. */
667
- spawnSpec(provider, extraArgs = [], resume = null, leadArgs = []) {
668
- const resolved = this.resolve(provider);
693
+ spawnSpec(provider, extraArgs = [], resume = null, leadArgs = [], shell = null) {
694
+ const resolved = shell ? shell.path : this.resolve(provider);
669
695
  if (!resolved) {
670
696
  const hint = provider.install ? ` ${provider.install}` : '';
671
697
  const err = new Error(`${provider.tool} ("${this.commandFor(provider)}") was not found on PATH.${hint}`);
@@ -683,7 +709,9 @@ export class ProviderRegistry extends EventEmitter {
683
709
  }
684
710
  resumeArgs = provider.resumeArgs.map((arg) => arg.replaceAll('{id}', resume));
685
711
  }
686
- return buildSpawnSpec(resolved, [...leadArgs, ...provider.args, ...resumeArgs, ...extraArgs], this.env, this.platform);
712
+ // The provider's own args were written for its default shell, not for one picked instead.
713
+ const args = shell && shell.id !== this.shellsFor(provider).defaultId ? [] : provider.args;
714
+ return buildSpawnSpec(resolved, [...(shell?.args ?? []), ...leadArgs, ...args, ...resumeArgs, ...extraArgs], this.env, this.platform);
687
715
  }
688
716
 
689
717
  async updateSpec(provider) {
@@ -93,25 +93,31 @@ export class SessionManager extends EventEmitter {
93
93
  return dir;
94
94
  }
95
95
 
96
- async create({ providerId, cwd, cols, rows, name, args, resume, account } = {}) {
96
+ async create({ providerId, cwd, cols, rows, name, args, resume, account, shell } = {}) {
97
97
  const provider = this.registry.get(String(providerId || ''));
98
98
  if (!provider) throw httpError(404, `unknown provider "${providerId}"`, 'unknown_provider');
99
99
  if (args !== undefined && (!Array.isArray(args) || args.some((a) => typeof a !== 'string'))) {
100
100
  throw httpError(400, 'args must be an array of strings', 'bad_args');
101
101
  }
102
102
  if (account !== undefined && account !== null && typeof account !== 'string') throw httpError(400, 'account must be a string', 'bad_account');
103
+ if (shell !== undefined && shell !== null && typeof shell !== 'string') throw httpError(400, 'shell must be a string', 'bad_shell');
103
104
  const resumeId = cleanResumeId(resume);
104
105
  const workDir = this.resolveCwd(cwd);
105
106
  const signIn = this.registry.account(provider, account);
107
+ const runShell = this.registry.shellFor(provider, shell);
106
108
  const hooks = this.sessionHooks ? await this.sessionHooks.launch(provider) : { args: [], reporting: null };
107
109
  // Checked after the await, so an install that started meanwhile is seen.
108
110
  if (this.installing.has(provider.id) || this.installsRunningFor(provider.id) > 0) {
109
111
  throw httpError(409, `${provider.tool} is being installed, updated or removed; start it once that finishes`, 'install_in_progress');
110
112
  }
111
- const spawnSpec = this.registry.spawnSpec(provider, args || [], resumeId, hooks.args);
113
+ const spawnSpec = this.registry.spawnSpec(provider, args || [], resumeId, hooks.args, runShell);
112
114
  this.prepareAccount(provider, signIn, { hooksSupplied: hooks.args.length > 0 });
113
- const sessionName = cleanName(name) || (provider.accounts.length > 1 ? `${provider.tool} · ${signIn.label}` : null);
114
- const session = this._spawn({ provider, spawnSpec, cwd: workDir, cols, rows, name: sessionName, resume: resumeId, account: signIn, reporting: hooks.reporting });
115
+ const sessionName = cleanName(name)
116
+ || (provider.accounts.length > 1 ? `${provider.tool} · ${signIn.label}` : null)
117
+ || (runShell && this.registry.shellsFor(provider).shells.length > 1 ? `${provider.tool} · ${runShell.label}` : null);
118
+ const session = this._spawn({
119
+ provider, spawnSpec, cwd: workDir, cols, rows, name: sessionName, resume: resumeId, account: signIn, reporting: hooks.reporting, extraEnv: runShell?.env,
120
+ });
115
121
  const model = modelFromArgs([...provider.args, ...(args || [])]);
116
122
  if (model) session.setModel({ name: model }, 'args');
117
123
  return session;
@@ -0,0 +1,82 @@
1
+ // The shells the Shell card can start: the ones installed on this computer,
2
+ // and which of them is the default.
3
+
4
+ import fs from 'node:fs';
5
+ import path from 'node:path';
6
+ import { resolveCommand } from './command-resolver.mjs';
7
+
8
+ const UNIX_SHELLS = [
9
+ { id: 'bash', label: 'bash' },
10
+ { id: 'zsh', label: 'zsh' },
11
+ { id: 'fish', label: 'fish' },
12
+ { id: 'pwsh', label: 'PowerShell' },
13
+ ];
14
+
15
+ function isFile(file) {
16
+ try { return fs.statSync(file).isFile(); } catch { return false; }
17
+ }
18
+
19
+ function envValue(env, name) {
20
+ const key = Object.keys(env).find((k) => k.toUpperCase() === name.toUpperCase());
21
+ return key ? env[key] : undefined;
22
+ }
23
+
24
+ // Git for Windows keeps bash.exe in <root>\bin, and git.exe in <root>\cmd,
25
+ // <root>\bin or <root>\mingw64\bin.
26
+ function findGitBash(env, resolve, exists) {
27
+ const p = path.win32;
28
+ const roots = [];
29
+ const git = resolve('git');
30
+ if (git) for (let dir = p.dirname(git), i = 0; i < 3; i++, dir = p.dirname(dir)) roots.push(dir);
31
+ for (const [name, sub] of [['ProgramFiles', 'Git'], ['ProgramW6432', 'Git'], ['ProgramFiles(x86)', 'Git'], ['LOCALAPPDATA', 'Programs\\Git'], ['USERPROFILE', 'scoop\\apps\\git\\current']]) {
32
+ const base = envValue(env, name);
33
+ if (base) roots.push(p.join(base, sub));
34
+ }
35
+ for (const root of roots) {
36
+ const bash = p.join(root, 'bin', 'bash.exe');
37
+ if (exists(bash) && exists(p.join(root, 'cmd', 'git.exe'))) return bash;
38
+ }
39
+ return null;
40
+ }
41
+
42
+ /** The shell to name when none is installed. */
43
+ export function fallbackShell(env, platform) {
44
+ if (platform === 'win32') return 'powershell.exe';
45
+ return env.SHELL || (platform === 'darwin' ? '/bin/zsh' : '/bin/bash');
46
+ }
47
+
48
+ function windowsShells(env, resolve, exists) {
49
+ const installed = ['ProgramFiles', 'ProgramW6432'].map((name) => envValue(env, name)).filter(Boolean).map((base) => path.win32.join(base, 'PowerShell', '7', 'pwsh.exe'));
50
+ const pwsh = resolve('pwsh') || installed.find(exists) || null;
51
+ const gitBash = findGitBash(env, resolve, exists);
52
+ const shells = [
53
+ pwsh && { id: 'pwsh', label: 'PowerShell', path: pwsh, args: [], env: {} },
54
+ { id: 'powershell', label: 'Windows PowerShell', path: resolve('powershell.exe'), args: [], env: {} },
55
+ { id: 'cmd', label: 'Command Prompt', path: resolve('cmd.exe'), args: [], env: {} },
56
+ // CHERE_INVOKING keeps a login bash in the session's folder instead of moving to HOME.
57
+ gitBash && { id: 'git-bash', label: 'Git Bash', path: gitBash, args: ['--login', '-i'], env: { CHERE_INVOKING: '1' } },
58
+ ].filter((shell) => shell?.path);
59
+ return { shells, defaultId: shells[0]?.id ?? null };
60
+ }
61
+
62
+ function unixShells(env, platform, resolve) {
63
+ const name = (file) => path.posix.basename(file);
64
+ const own = resolve(env.SHELL || '') || resolve(fallbackShell({}, platform));
65
+ const shells = [];
66
+ for (const { id, label } of UNIX_SHELLS) {
67
+ const found = own && name(own) === id ? own : resolve(id);
68
+ if (found) shells.push({ id, label, path: found, args: [], env: {} });
69
+ }
70
+ let defaultId = own ? name(own) : null;
71
+ if (own && !shells.some((shell) => shell.id === defaultId)) {
72
+ if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,31}$/.test(defaultId)) defaultId = 'login';
73
+ shells.unshift({ id: defaultId, label: name(own), path: own, args: [], env: {} });
74
+ }
75
+ return { shells, defaultId: defaultId ?? shells[0]?.id ?? null };
76
+ }
77
+
78
+ /** The installed shells, as { id, label, path, args, env }, and the id of the default one. */
79
+ export function detectShells(env = process.env, platform = process.platform, { exists = isFile, resolve: resolveWith = resolveCommand } = {}) {
80
+ const resolve = (command) => resolveWith(command, env, platform);
81
+ return platform === 'win32' ? windowsShells(env, resolve, exists) : unixShells(env, platform, resolve);
82
+ }
package/web/app.js CHANGED
@@ -4,6 +4,7 @@
4
4
  const TOKEN_KEY = 'agentGuild.token';
5
5
  const CWD_KEY = 'agentGuild.cwd';
6
6
  const ACCOUNTS_KEY = 'agentGuild.accounts';
7
+ const SHELLS_KEY = 'agentGuild.shells';
7
8
  const THEME_KEY = 'agentGuild.theme';
8
9
  const SKIN_KEY = 'agentGuild.skin';
9
10
  const NEWS_SEEN_KEY = 'agentGuild.newsSeen';
@@ -23,6 +24,7 @@ const state = {
23
24
  providers: [],
24
25
  usage: new Map(),
25
26
  accounts: {},
27
+ shellPicks: {},
26
28
  stats: null,
27
29
  statsFor: new Map(),
28
30
  news: null,
@@ -456,6 +458,51 @@ function selectAccount(provider, id) {
456
458
  save(ACCOUNTS_KEY, JSON.stringify(state.accounts));
457
459
  }
458
460
 
461
+ /** The shell the user picked for the provider, or null to start its default. */
462
+ function pickedShell(provider) {
463
+ return provider.shells?.find((s) => s.id === state.shellPicks[provider.id] && s.id !== provider.defaultShell) ?? null;
464
+ }
465
+
466
+ function selectedShell(provider) {
467
+ return pickedShell(provider) ?? provider.shells?.find((s) => s.id === provider.defaultShell) ?? null;
468
+ }
469
+
470
+ function selectShell(provider, id) {
471
+ if (id === provider.defaultShell) delete state.shellPicks[provider.id];
472
+ else state.shellPicks[provider.id] = id;
473
+ save(SHELLS_KEY, JSON.stringify(state.shellPicks));
474
+ }
475
+
476
+ function renderShells(card, provider) {
477
+ const host = card.querySelector('.shells');
478
+ const shells = provider.shells || [];
479
+ host.hidden = !provider.available || shells.length < 2;
480
+ if (host.hidden) return host.replaceChildren();
481
+ const selected = selectedShell(provider)?.id;
482
+ const same = host.children.length === shells.length && shells.every((s, i) => host.children[i].dataset.shell === s.id);
483
+ if (!same) {
484
+ host.replaceChildren(...shells.map((shell) => {
485
+ const chip = document.createElement('button');
486
+ chip.type = 'button';
487
+ chip.className = 'account-chip';
488
+ chip.setAttribute('role', 'tab');
489
+ chip.dataset.shell = shell.id;
490
+ chip.addEventListener('click', () => {
491
+ selectShell(provider, shell.id);
492
+ renderShells(card, provider);
493
+ renderUsage(card, provider);
494
+ });
495
+ return chip;
496
+ }));
497
+ }
498
+ shells.forEach((shell, i) => {
499
+ const chip = host.children[i];
500
+ chip.setAttribute('aria-selected', String(shell.id === selected));
501
+ chip.textContent = shell.label;
502
+ chip.title = `Start new sessions in ${shell.label}${shell.id === provider.defaultShell ? ', used unless you pick another' : ''}\n${shell.path}`;
503
+ });
504
+ }
505
+
459
506
  function usageFor(provider, account = selectedAccount(provider)) {
460
507
  return state.usage.get(`${provider.id}/${account.id}`);
461
508
  }
@@ -559,6 +606,7 @@ function renderProviders() {
559
606
  renderCopies(node.querySelector('.copies'), provider, node);
560
607
  renderVendorLinks(node, provider);
561
608
  renderAccounts(node, provider);
609
+ renderShells(node, provider);
562
610
  renderUsage(node, provider);
563
611
  renderReportingSetup(node, provider);
564
612
  renderModelStats(node, provider);
@@ -685,7 +733,7 @@ function renderUsage(card, provider) {
685
733
  start.textContent = unsigned ? 'Sign in' : 'New';
686
734
  start.title = unsigned
687
735
  ? `Start a ${provider.tool} session and sign in as the ${account.label} account`
688
- : `Start a new ${provider.tool} session${provider.accounts?.length > 1 ? ` as the ${account.label} account` : ''}`;
736
+ : `Start a new ${provider.tool} session${provider.accounts?.length > 1 ? ` as the ${account.label} account` : ''}${provider.shells?.length > 1 ? ` in ${selectedShell(provider)?.label}` : ''}`;
689
737
  if (!provider.available || !provider.usageSource || !usage) return host.replaceChildren();
690
738
  if (usage.error || usage.windows.length === 0) {
691
739
  const note = document.createElement('div');
@@ -1728,7 +1776,7 @@ async function startSession(provider, card, { resume, cwd, account = selectedAcc
1728
1776
  save(CWD_KEY, working);
1729
1777
  card?.classList.add('busy');
1730
1778
  try {
1731
- const body = { providerId: provider.id, account, cwd: cwd || working || undefined, cols: 120, rows: 32, resume };
1779
+ const body = { providerId: provider.id, account, shell: pickedShell(provider)?.id, cwd: cwd || working || undefined, cols: 120, rows: 32, resume };
1732
1780
  let session;
1733
1781
  try {
1734
1782
  ({ session } = await api('POST', '/sessions', body));
@@ -3403,8 +3451,10 @@ $('panel-stop').addEventListener('click', () => {
3403
3451
  });
3404
3452
  $('cwd').value = load(CWD_KEY) || '';
3405
3453
  try { state.accounts = JSON.parse(load(ACCOUNTS_KEY)) || {}; } catch { state.accounts = {}; }
3454
+ try { state.shellPicks = JSON.parse(load(SHELLS_KEY)) || {}; } catch { state.shellPicks = {}; }
3406
3455
  githubView.accountId = Number(load(GITHUB_ACCOUNT_KEY)) || null;
3407
3456
  if (typeof state.accounts !== 'object' || Array.isArray(state.accounts)) state.accounts = {};
3457
+ if (typeof state.shellPicks !== 'object' || Array.isArray(state.shellPicks)) state.shellPicks = {};
3408
3458
  setInterval(renderSessions, 30000);
3409
3459
  setInterval(tickNews, 30000);
3410
3460
  setInterval(() => { if ($('github').open && state.github) renderGitHub(); }, 30000);
package/web/index.html CHANGED
@@ -262,6 +262,7 @@
262
262
  <a class="cloud-link" target="_blank" rel="noopener noreferrer" hidden></a>
263
263
  </div>
264
264
  <div class="accounts" role="tablist" aria-label="Accounts" hidden></div>
265
+ <div class="shells" role="tablist" aria-label="Shells" hidden></div>
265
266
  <div class="usage"></div>
266
267
  <div class="model-stats"></div>
267
268
  <div class="reporting-row" hidden>
Binary file
Binary file
Binary file
Binary file
package/web/styles.css CHANGED
@@ -109,7 +109,7 @@ main { max-width: 1200px; margin: 0 auto; padding: 26px 16px 56px; display: grid
109
109
  .provider .state.update-available { color: var(--warn); }
110
110
  .provider.unavailable .state { color: var(--muted); }
111
111
  .provider .update { color: var(--warn); border-color: var(--warn); }
112
- .provider .accounts { display: flex; flex-wrap: wrap; gap: 6px; }
112
+ .provider .accounts, .provider .shells { display: flex; flex-wrap: wrap; gap: 6px; }
113
113
  .account-chip {
114
114
  font: inherit; font-size: 0.75rem; font-weight: 600; padding: 2px 10px; border-radius: 999px; cursor: pointer;
115
115
  border: 1px solid var(--border); background: var(--surface-2); color: var(--muted); max-width: 100%;