@songsid/agend 2.1.4-beta.27 → 2.1.4-beta.29

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") {
@@ -2379,6 +2375,8 @@ export class FleetManager {
2379
2375
  return;
2380
2376
  if (await this.handleLoginConfirm(data, adapterId, this.adapter ?? undefined))
2381
2377
  return;
2378
+ if (await this.handleInstallLoginConfirm(data, adapterId, this.adapter ?? undefined))
2379
+ return;
2382
2380
  if (await this.handleClearConfirmation(data, adapterId, this.adapter ?? undefined))
2383
2381
  return;
2384
2382
  if (await this.handleExitRestartPrompt(data, adapterId, this.adapter ?? undefined))
@@ -2508,6 +2506,12 @@ export class FleetManager {
2508
2506
  });
2509
2507
  await data.respond(result);
2510
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
+ }
2511
2515
  else if (data.command === "clear") {
2512
2516
  await this.handleClearSlash(data, adapterId);
2513
2517
  }
@@ -2672,6 +2676,12 @@ export class FleetManager {
2672
2676
  });
2673
2677
  await data.respond(result);
2674
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
+ }
2675
2685
  }, this.logger, "adapter.slash_command"));
2676
2686
  await this.topicCommands.registerBotCommands().catch(e => this.logger.warn({ err: e }, "registerBotCommands failed (non-fatal)"));
2677
2687
  // Background-probe each backend's CLI env (version/models) → cli-env cache.
@@ -2758,6 +2768,8 @@ export class FleetManager {
2758
2768
  return;
2759
2769
  if (await this.handleLoginConfirm(data, adapterId, adapter))
2760
2770
  return;
2771
+ if (await this.handleInstallLoginConfirm(data, adapterId, adapter))
2772
+ return;
2761
2773
  if (await this.handleClearConfirmation(data, adapterId, adapter))
2762
2774
  return;
2763
2775
  if (await this.handleExitRestartPrompt(data, adapterId, adapter))
@@ -3004,6 +3016,12 @@ export class FleetManager {
3004
3016
  });
3005
3017
  await data.respond(result);
3006
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
+ }
3007
3025
  }, this.logger, `adapter[${adapterId}].slash_command`));
3008
3026
  await adapter.start();
3009
3027
  if (channelConfig.group_id) {
@@ -6242,6 +6260,9 @@ export class FleetManager {
6242
6260
  return t("login.unsupported", backendArg);
6243
6261
  if (this.activeLogin)
6244
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");
6245
6266
  // Token-free pre-check (5s cap): re-login while auth still works is
6246
6267
  // usually a mistake, so it needs a confirmed click. An invalid OR
6247
6268
  // uncertain result (timeout, missing binary) proceeds straight to login —
@@ -6273,6 +6294,9 @@ export class FleetManager {
6273
6294
  async launchLoginSession(flow, backend, chat) {
6274
6295
  if (this.activeLogin)
6275
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");
6276
6300
  const sessionName = getTmuxSession();
6277
6301
  await TmuxManager.ensureSession(sessionName);
6278
6302
  const tmux = new TmuxManager(sessionName, "");
@@ -6306,7 +6330,9 @@ export class FleetManager {
6306
6330
  text = t("login.success", backend, woken.length ? woken.join(", ") : none, restarted.length ? restarted.join(", ") : none);
6307
6331
  }
6308
6332
  else if (detail === "cancelled") {
6309
- 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;
6310
6336
  }
6311
6337
  else {
6312
6338
  text = t("login.failed", backend, detail);
@@ -6451,6 +6477,173 @@ export class FleetManager {
6451
6477
  await this.retireNonceButtons(entry, entry.messageId ?? data.messageId, ok ? t("login.provider_selected", label) : t("login.no_session"));
6452
6478
  return true;
6453
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
+ }
6454
6647
  queueMirrorMessage(text) {
6455
6648
  const mirrorTopicId = this.fleetConfig?.channel?.mirror_topic_id;
6456
6649
  if (mirrorTopicId == null || !this.adapter)
@@ -6622,9 +6815,8 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
6622
6815
  }
6623
6816
  /** Resolve the workspace path a backend actually uses before publishing knowledge. */
6624
6817
  resolveKnowledgeWorkDir(workDir, backend, instanceName) {
6625
- // Some backends relocate their real cwd (agy moves hidden paths to
6626
- // ~/agend-workspaces/<name>). Instructions and skills must land where the
6627
- // 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.
6628
6820
  try {
6629
6821
  const resolved = createBackend(backend, join(getAgendHome(), "cli-env"))
6630
6822
  .resolveWorkingDirectory?.(workDir, instanceName);