@songsid/agend 2.1.4-beta.26 → 2.1.4-beta.28

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.
@@ -116,6 +116,15 @@ export interface FleetContext {
116
116
  loginSubmitInput?(text: string): Promise<string>;
117
117
  /** Abort the active login session. */
118
118
  cancelLoginSession?(): Promise<string>;
119
+ /** `/install-cli <backend>` — install a CLI in a dedicated window; returns a status line. */
120
+ startInstallSession?(backend: string, chat: {
121
+ adapter: import("./channel/types.js").ChannelAdapter;
122
+ adapterId: string;
123
+ chatId: string;
124
+ threadId?: string;
125
+ }): Promise<string>;
126
+ /** Abort the active install session. */
127
+ cancelInstallSession?(): Promise<string>;
119
128
  /** Human-readable effective model for an instance (resolves inherited defaults). */
120
129
  modelDisplayForInstance?(name: string): string;
121
130
  /**
@@ -857,6 +857,24 @@ export declare class FleetManager implements FleetContext, LifecycleContext, Arc
857
857
  private handleLoginConfirm;
858
858
  /** Kiro provider button → drive the CLI's arrow-key selector. */
859
859
  private handleLoginMenuSelect;
860
+ private activeInstall;
861
+ /** Start a CLI install session. Caller enforces admin. */
862
+ startInstallSession(backendArg: string, chat: {
863
+ adapter: ChannelAdapter;
864
+ adapterId: string;
865
+ chatId: string;
866
+ threadId?: string;
867
+ }): Promise<string>;
868
+ /** `/install-cli cancel` — abort the active install and remove its window. */
869
+ cancelInstallSession(): Promise<string>;
870
+ /** `command -v` on a login shell, so PATH additions from rc files count. */
871
+ private verifyBinaryOnLoginShell;
872
+ /** "Sign in now?" button after a successful install. */
873
+ private handleInstallLoginConfirm;
874
+ /** Discord native `/login` slash — shared by every adapter dispatch block. */
875
+ private handleLoginSlash;
876
+ /** Discord native `/install-cli` slash — shared by every dispatch block. */
877
+ private handleInstallCliSlash;
860
878
  queueMirrorMessage(text: string): void;
861
879
  /** Push an SSE event to all connected Web UI clients. */
862
880
  emitSseEvent(event: string, data: unknown): void;
@@ -1,5 +1,6 @@
1
1
  import { existsSync, readFileSync, mkdirSync, writeFileSync, unlinkSync, rmSync, readdirSync, renameSync, copyFileSync, chmodSync, statSync } from "node:fs";
2
2
  import { randomBytes } from "node:crypto";
3
+ import { spawnSync } from "node:child_process";
3
4
  import { createServer } from "node:http";
4
5
  import { join, dirname, basename } from "node:path";
5
6
  import { fileURLToPath } from "node:url";
@@ -214,6 +215,7 @@ const TIP_UNLOCK_CALLBACK_PREFIX = "tip-unlock:";
214
215
  const LOGIN_CALLBACK_PREFIX = "login:";
215
216
  const LOGIN_MENU_CALLBACK_PREFIX = "login-menu:";
216
217
  const LOGIN_CONFIRM_CALLBACK_PREFIX = "login-confirm:";
218
+ const INSTALL_LOGIN_CALLBACK_PREFIX = "install-login:";
217
219
  const CLEAR_CONFIRM_TIMEOUT_MS = 15_000;
218
220
  /** Default lifetime for long-lived nonce prompts (clear overrides this to 15s). */
219
221
  const NONCE_BUTTON_TIMEOUT_MS = 15 * 60_000;
@@ -1273,12 +1275,6 @@ export class FleetManager {
1273
1275
  }
1274
1276
  const backend = config.backend ?? this.fleetConfig?.defaults?.backend ?? "claude-code";
1275
1277
  if (config.general_topic) {
1276
- // antigravity (agy) does not read MCP instructions — fleet context and
1277
- // routing instructions are not injected, so it cannot act as a dispatcher.
1278
- if (backend === "antigravity") {
1279
- this.logger.warn({ name }, "antigravity backend does not support MCP instructions — general dispatcher will not work correctly");
1280
- this.notifyInstanceTopic(name, t("general.antigravity_unsupported"));
1281
- }
1282
1278
  this.ensureGeneralInstructions(config.working_directory, backend, name);
1283
1279
  }
1284
1280
  else if (kind === "fleet-topic") {
@@ -1674,6 +1670,10 @@ export class FleetManager {
1674
1670
  const { getTmuxSocketName: getSocket } = await import("./paths.js");
1675
1671
  TmuxManager.setSocketName(getSocket());
1676
1672
  await TmuxManager.ensureSession(getTmuxSession());
1673
+ // Pre-flight (advisory, fire-and-forget): warm the per-backend auth cache
1674
+ // before instances spawn, so a CLI that comes up on its sign-in screen gets
1675
+ // an instant 🔑 verdict instead of decaying into crash/MCP-died noise.
1676
+ this.lifecycle.primeAuthVerification(Object.values(fleet.instances).map(config => config.backend ?? fleet.defaults?.backend ?? "claude-code"));
1677
1677
  // Start tmux control mode client for idle detection
1678
1678
  if (!this.controlClient) {
1679
1679
  this.controlClient = new TmuxControlClient(getTmuxSession(), 2000, this.logger);
@@ -2375,6 +2375,8 @@ export class FleetManager {
2375
2375
  return;
2376
2376
  if (await this.handleLoginConfirm(data, adapterId, this.adapter ?? undefined))
2377
2377
  return;
2378
+ if (await this.handleInstallLoginConfirm(data, adapterId, this.adapter ?? undefined))
2379
+ return;
2378
2380
  if (await this.handleClearConfirmation(data, adapterId, this.adapter ?? undefined))
2379
2381
  return;
2380
2382
  if (await this.handleExitRestartPrompt(data, adapterId, this.adapter ?? undefined))
@@ -2504,6 +2506,12 @@ export class FleetManager {
2504
2506
  });
2505
2507
  await data.respond(result);
2506
2508
  }
2509
+ else if (data.command === "login") {
2510
+ await this.handleLoginSlash(data, adapterId, this.adapter);
2511
+ }
2512
+ else if (data.command === "install-cli") {
2513
+ await this.handleInstallCliSlash(data, adapterId, this.adapter);
2514
+ }
2507
2515
  else if (data.command === "clear") {
2508
2516
  await this.handleClearSlash(data, adapterId);
2509
2517
  }
@@ -2668,6 +2676,12 @@ export class FleetManager {
2668
2676
  });
2669
2677
  await data.respond(result);
2670
2678
  }
2679
+ else if (data.command === "login") {
2680
+ await this.handleLoginSlash(data, adapterId, this.adapter);
2681
+ }
2682
+ else if (data.command === "install-cli") {
2683
+ await this.handleInstallCliSlash(data, adapterId, this.adapter);
2684
+ }
2671
2685
  }, this.logger, "adapter.slash_command"));
2672
2686
  await this.topicCommands.registerBotCommands().catch(e => this.logger.warn({ err: e }, "registerBotCommands failed (non-fatal)"));
2673
2687
  // Background-probe each backend's CLI env (version/models) → cli-env cache.
@@ -2754,6 +2768,8 @@ export class FleetManager {
2754
2768
  return;
2755
2769
  if (await this.handleLoginConfirm(data, adapterId, adapter))
2756
2770
  return;
2771
+ if (await this.handleInstallLoginConfirm(data, adapterId, adapter))
2772
+ return;
2757
2773
  if (await this.handleClearConfirmation(data, adapterId, adapter))
2758
2774
  return;
2759
2775
  if (await this.handleExitRestartPrompt(data, adapterId, adapter))
@@ -3000,6 +3016,12 @@ export class FleetManager {
3000
3016
  });
3001
3017
  await data.respond(result);
3002
3018
  }
3019
+ else if (data.command === "login") {
3020
+ await this.handleLoginSlash(data, adapterId, adapter);
3021
+ }
3022
+ else if (data.command === "install-cli") {
3023
+ await this.handleInstallCliSlash(data, adapterId, adapter);
3024
+ }
3003
3025
  }, this.logger, `adapter[${adapterId}].slash_command`));
3004
3026
  await adapter.start();
3005
3027
  if (channelConfig.group_id) {
@@ -6238,6 +6260,9 @@ export class FleetManager {
6238
6260
  return t("login.unsupported", backendArg);
6239
6261
  if (this.activeLogin)
6240
6262
  return t("login.busy", this.activeLogin.backend);
6263
+ // Install sessions share the window namespace — never run both at once.
6264
+ if (this.activeInstall)
6265
+ return t("install.busy");
6241
6266
  // Token-free pre-check (5s cap): re-login while auth still works is
6242
6267
  // usually a mistake, so it needs a confirmed click. An invalid OR
6243
6268
  // uncertain result (timeout, missing binary) proceeds straight to login —
@@ -6269,6 +6294,9 @@ export class FleetManager {
6269
6294
  async launchLoginSession(flow, backend, chat) {
6270
6295
  if (this.activeLogin)
6271
6296
  return t("login.busy", this.activeLogin.backend);
6297
+ // Install sessions share the window namespace — never run both at once.
6298
+ if (this.activeInstall)
6299
+ return t("install.busy");
6272
6300
  const sessionName = getTmuxSession();
6273
6301
  await TmuxManager.ensureSession(sessionName);
6274
6302
  const tmux = new TmuxManager(sessionName, "");
@@ -6302,7 +6330,9 @@ export class FleetManager {
6302
6330
  text = t("login.success", backend, woken.length ? woken.join(", ") : none, restarted.length ? restarted.join(", ") : none);
6303
6331
  }
6304
6332
  else if (detail === "cancelled") {
6305
- text = t("login.cancelled", backend);
6333
+ // The cancel command's own reply already announced this — a second
6334
+ // message here was a duplicate.
6335
+ return;
6306
6336
  }
6307
6337
  else {
6308
6338
  text = t("login.failed", backend, detail);
@@ -6447,6 +6477,173 @@ export class FleetManager {
6447
6477
  await this.retireNonceButtons(entry, entry.messageId ?? data.messageId, ok ? t("login.provider_selected", label) : t("login.no_session"));
6448
6478
  return true;
6449
6479
  }
6480
+ // ── Remote CLI install (`/install-cli`) ──────────────────────────────────
6481
+ //
6482
+ // Same dedicated-window model as /login (and the same LoginSession state
6483
+ // machine — an install is a login flow with no auth hints): run the
6484
+ // installer, judge by exit code, verify the binary on a fresh login shell,
6485
+ // then offer to chain straight into /login.
6486
+ activeInstall = null;
6487
+ /** Start a CLI install session. Caller enforces admin. */
6488
+ async startInstallSession(backendArg, chat) {
6489
+ const backend = LOGIN_BACKEND_ALIASES[backendArg.toLowerCase()] ?? backendArg.toLowerCase();
6490
+ const info = BACKEND_INSTALLATION_INFO[backend];
6491
+ if (!info)
6492
+ return t("install.unsupported", backendArg);
6493
+ if (checkBinaryInstalled(info.binary))
6494
+ return t("install.already", backend, info.binary);
6495
+ if (this.activeInstall)
6496
+ return t("install.busy");
6497
+ if (this.activeLogin)
6498
+ return t("login.busy", this.activeLogin.backend);
6499
+ const sessionName = getTmuxSession();
6500
+ await TmuxManager.ensureSession(sessionName);
6501
+ const tmux = new TmuxManager(sessionName, "");
6502
+ // A synthetic login flow: success is decided by the installer's exit code
6503
+ // (LoginSession treats a clean exit as success), never by pane text — and
6504
+ // installer output that happens to contain a URL must not be forwarded as
6505
+ // an auth hint, hence the no-op events below.
6506
+ const flow = {
6507
+ backend,
6508
+ command: info.install,
6509
+ successPattern: /$^/,
6510
+ timeoutMs: 10 * 60 * 1000,
6511
+ };
6512
+ const session = new LoginSession(flow, tmux, {
6513
+ onMenu: () => { },
6514
+ onAuthHint: () => { },
6515
+ onNeedInput: () => { },
6516
+ onDone: async ({ ok, detail }) => {
6517
+ this.activeInstall = null;
6518
+ if (!ok) {
6519
+ // A cancel is user-initiated — the cancel command's own reply already
6520
+ // said so; a second message here would be a duplicate.
6521
+ if (detail !== "cancelled") {
6522
+ await chat.adapter.sendText(chat.chatId, t("install.failed", backend, detail), { threadId: chat.threadId }).catch(() => { });
6523
+ }
6524
+ return;
6525
+ }
6526
+ // The installer may only have added the binary to a profile PATH; a
6527
+ // fresh login shell sees that, the fleet process's PATH may not.
6528
+ if (!this.verifyBinaryOnLoginShell(info.binary)) {
6529
+ await chat.adapter.sendText(chat.chatId, t("install.verify_failed", backend, info.binary), { threadId: chat.threadId }).catch(() => { });
6530
+ return;
6531
+ }
6532
+ if (!LOGIN_FLOWS[backend]) {
6533
+ await chat.adapter.sendText(chat.chatId, t("install.success_no_login", backend), { threadId: chat.threadId }).catch(() => { });
6534
+ return;
6535
+ }
6536
+ await this.postNonceButtonPrompt({
6537
+ prefix: INSTALL_LOGIN_CALLBACK_PREFIX,
6538
+ alertType: "login",
6539
+ instanceName: backend,
6540
+ adapter: chat.adapter,
6541
+ adapterId: chat.adapterId,
6542
+ chatId: chat.chatId,
6543
+ threadId: chat.threadId,
6544
+ message: t("install.login_prompt", backend),
6545
+ choices: [
6546
+ { action: "go", label: t("install.login_now") },
6547
+ { action: "later", label: t("install.later") },
6548
+ ],
6549
+ expiredText: t("install.later_ack", backend),
6550
+ });
6551
+ },
6552
+ }, this.logger);
6553
+ this.activeInstall = { session, backend, chat };
6554
+ try {
6555
+ await session.start();
6556
+ }
6557
+ catch (err) {
6558
+ this.activeInstall = null;
6559
+ return t("install.failed", backend, err.message);
6560
+ }
6561
+ return t("install.started", backend);
6562
+ }
6563
+ /** `/install-cli cancel` — abort the active install and remove its window. */
6564
+ async cancelInstallSession() {
6565
+ if (!this.activeInstall)
6566
+ return t("install.no_session");
6567
+ const backend = this.activeInstall.backend;
6568
+ await this.activeInstall.session.cancel();
6569
+ return t("install.cancelled", backend);
6570
+ }
6571
+ /** `command -v` on a login shell, so PATH additions from rc files count. */
6572
+ verifyBinaryOnLoginShell(binary) {
6573
+ try {
6574
+ const result = spawnSync("bash", ["-lc", `command -v ${binary}`], { timeout: 10_000, stdio: "pipe" });
6575
+ return result.status === 0;
6576
+ }
6577
+ catch {
6578
+ return false;
6579
+ }
6580
+ }
6581
+ /** "Sign in now?" button after a successful install. */
6582
+ async handleInstallLoginConfirm(data, callbackAdapterId, receivingAdapter) {
6583
+ const claimed = this.consumeNonceCallback(INSTALL_LOGIN_CALLBACK_PREFIX, /^install-login:([0-9a-f]+):(go|later)$/, data, callbackAdapterId, receivingAdapter);
6584
+ if (claimed === null)
6585
+ return false;
6586
+ if (claimed === "consumed")
6587
+ return true;
6588
+ const { entry, action } = claimed;
6589
+ const backend = entry.instanceName;
6590
+ if (action === "later") {
6591
+ await this.retireNonceButtons(entry, entry.messageId ?? data.messageId, t("install.later_ack", backend));
6592
+ return true;
6593
+ }
6594
+ await this.retireNonceButtons(entry, entry.messageId ?? data.messageId, t("login.starting_backend", backend));
6595
+ const text = await this.startLoginSession(backend, {
6596
+ adapter: entry.adapter,
6597
+ adapterId: entry.adapterId,
6598
+ chatId: entry.chatId,
6599
+ threadId: entry.threadId,
6600
+ });
6601
+ if (text)
6602
+ await entry.adapter.sendText(entry.chatId, text, { threadId: entry.threadId }).catch(() => { });
6603
+ return true;
6604
+ }
6605
+ /** Discord native `/login` slash — shared by every adapter dispatch block. */
6606
+ async handleLoginSlash(data, adapterId, adapter) {
6607
+ if (!data.userId || !this.isFleetAdmin(data.userId, adapterId)) {
6608
+ await data.respond(t("permission.denied"));
6609
+ return;
6610
+ }
6611
+ const chat = { adapter, adapterId, chatId: data.channelId };
6612
+ if (data.options?.cancel === true) {
6613
+ await data.respond(await this.cancelLoginSession());
6614
+ return;
6615
+ }
6616
+ const code = String(data.options?.code ?? "").trim();
6617
+ if (code) {
6618
+ await data.respond(await this.loginSubmitInput(code));
6619
+ return;
6620
+ }
6621
+ const backend = String(data.options?.backend ?? "").trim();
6622
+ if (backend) {
6623
+ const text = await this.startLoginSession(backend, chat);
6624
+ await data.respond(text ?? t("login.confirm_posted"));
6625
+ return;
6626
+ }
6627
+ await this.promptLoginBackends(chat);
6628
+ await data.respond(t("login.chooser_posted"));
6629
+ }
6630
+ /** Discord native `/install-cli` slash — shared by every dispatch block. */
6631
+ async handleInstallCliSlash(data, adapterId, adapter) {
6632
+ if (!data.userId || !this.isFleetAdmin(data.userId, adapterId)) {
6633
+ await data.respond(t("permission.denied"));
6634
+ return;
6635
+ }
6636
+ if (data.options?.cancel === true) {
6637
+ await data.respond(await this.cancelInstallSession());
6638
+ return;
6639
+ }
6640
+ const backend = String(data.options?.backend ?? "").trim();
6641
+ if (!backend) {
6642
+ await data.respond(t("install.usage"));
6643
+ return;
6644
+ }
6645
+ await data.respond(await this.startInstallSession(backend, { adapter, adapterId, chatId: data.channelId }));
6646
+ }
6450
6647
  queueMirrorMessage(text) {
6451
6648
  const mirrorTopicId = this.fleetConfig?.channel?.mirror_topic_id;
6452
6649
  if (mirrorTopicId == null || !this.adapter)
@@ -6618,9 +6815,8 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
6618
6815
  }
6619
6816
  /** Resolve the workspace path a backend actually uses before publishing knowledge. */
6620
6817
  resolveKnowledgeWorkDir(workDir, backend, instanceName) {
6621
- // Some backends relocate their real cwd (agy moves hidden paths to
6622
- // ~/agend-workspaces/<name>). Instructions and skills must land where the
6623
- // CLI actually runs, not where fleet.yaml points.
6818
+ // Backend resolution may create/normalize the real cwd. Instructions and
6819
+ // skills must land where the CLI actually runs, not an assumed path.
6624
6820
  try {
6625
6821
  const resolved = createBackend(backend, join(getAgendHome(), "cli-env"))
6626
6822
  .resolveWorkingDirectory?.(workDir, instanceName);