@songsid/agend 2.1.5-beta.13 → 2.1.5-beta.15

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.
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Run work under a wall-clock deadline without cancelling it.
3
+ *
4
+ * Nothing here aborts anything — a tmux spawn or an HTTP call keeps going after
5
+ * its deadline passes; the caller simply stops waiting and can say so. That is
6
+ * the point: a step with no deadline cannot report anything while it hangs, so
7
+ * the user sees silence and assumes the whole operation is stuck (#722, and the
8
+ * post-login recovery that silently swallowed its own success message).
9
+ *
10
+ * Rejections are reported rather than thrown, so one failed step never takes
11
+ * the surrounding loop down with it, and are always observed — an abandoned
12
+ * promise that rejects later must not surface as an unhandled rejection.
13
+ */
14
+ export type DeadlineResult<T> = {
15
+ status: "fulfilled";
16
+ value: T;
17
+ } | {
18
+ status: "rejected";
19
+ reason: unknown;
20
+ } | {
21
+ status: "timeout";
22
+ };
23
+ export declare function runBeforeDeadline<T>(start: () => Promise<T>, deadline: number): Promise<DeadlineResult<T>>;
@@ -0,0 +1,29 @@
1
+ /** Largest delay setTimeout honours; anything above is clamped to 1ms by Node. */
2
+ const MAX_TIMER_MS = 2_147_483_647;
3
+ export async function runBeforeDeadline(start, deadline) {
4
+ const remaining = deadline - Date.now();
5
+ if (remaining <= 0)
6
+ return { status: "timeout" };
7
+ let work;
8
+ try {
9
+ work = start();
10
+ }
11
+ catch (reason) {
12
+ // A synchronous throw from `start` is a rejection like any other.
13
+ return { status: "rejected", reason };
14
+ }
15
+ let timer;
16
+ const timeout = new Promise(resolve => {
17
+ // Node clamps a delay above the 32-bit maximum to 1ms — so a very distant
18
+ // deadline would fire IMMEDIATELY and report every step as timed out, the
19
+ // exact opposite of what the caller asked for. Cap instead.
20
+ timer = setTimeout(() => resolve({ status: "timeout" }), Math.min(remaining, MAX_TIMER_MS));
21
+ timer.unref?.();
22
+ });
23
+ const settled = work.then(value => ({ status: "fulfilled", value }), reason => ({ status: "rejected", reason }));
24
+ const result = await Promise.race([settled, timeout]);
25
+ if (timer)
26
+ clearTimeout(timer);
27
+ return result;
28
+ }
29
+ //# sourceMappingURL=deadline.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"deadline.js","sourceRoot":"","sources":["../src/deadline.ts"],"names":[],"mappings":"AAkBA,kFAAkF;AAClF,MAAM,YAAY,GAAG,aAAa,CAAC;AAEnC,MAAM,CAAC,KAAK,UAAU,iBAAiB,CAAI,KAAuB,EAAE,QAAgB;IAClF,MAAM,SAAS,GAAG,QAAQ,GAAG,IAAI,CAAC,GAAG,EAAE,CAAC;IACxC,IAAI,SAAS,IAAI,CAAC;QAAE,OAAO,EAAE,MAAM,EAAE,SAAS,EAAE,CAAC;IACjD,IAAI,IAAgB,CAAC;IACrB,IAAI,CAAC;QACH,IAAI,GAAG,KAAK,EAAE,CAAC;IACjB,CAAC;IAAC,OAAO,MAAM,EAAE,CAAC;QAChB,kEAAkE;QAClE,OAAO,EAAE,MAAM,EAAE,UAAU,EAAE,MAAM,EAAE,CAAC;IACxC,CAAC;IACD,IAAI,KAAgD,CAAC;IACrD,MAAM,OAAO,GAAG,IAAI,OAAO,CAAoB,OAAO,CAAC,EAAE;QACvD,0EAA0E;QAC1E,0EAA0E;QAC1E,4DAA4D;QAC5D,KAAK,GAAG,UAAU,CAAC,GAAG,EAAE,CAAC,OAAO,CAAC,EAAE,MAAM,EAAE,SAAS,EAAE,CAAC,EAAE,IAAI,CAAC,GAAG,CAAC,SAAS,EAAE,YAAY,CAAC,CAAC,CAAC;QAC5F,KAAK,CAAC,KAAK,EAAE,EAAE,CAAC;IAClB,CAAC,CAAC,CAAC;IACH,MAAM,OAAO,GAAG,IAAI,CAAC,IAAI,CACvB,KAAK,CAAC,EAAE,CAAC,CAAC,EAAE,MAAM,EAAE,WAAW,EAAE,KAAK,EAAE,CAAC,EACzC,MAAM,CAAC,EAAE,CAAC,CAAC,EAAE,MAAM,EAAE,UAAU,EAAE,MAAM,EAAE,CAAC,CAC3C,CAAC;IACF,MAAM,MAAM,GAAG,MAAM,OAAO,CAAC,IAAI,CAAC,CAAC,OAAO,EAAE,OAAO,CAAC,CAAC,CAAC;IACtD,IAAI,KAAK;QAAE,YAAY,CAAC,KAAK,CAAC,CAAC;IAC/B,OAAO,MAAM,CAAC;AAChB,CAAC"}
@@ -66,6 +66,7 @@ export interface DeliveryOptions {
66
66
  /** Test/operational override; normal deliveries use the 60 second backstop. */
67
67
  idleTimeoutMs?: number;
68
68
  }
69
+ export declare const LOGIN_CALLBACK_PREFIX = "login:";
69
70
  /**
70
71
  * Upper bound on a live probe driven by `/model`.
71
72
  *
@@ -928,6 +929,17 @@ export declare class FleetManager implements FleetContext, LifecycleContext, Arc
928
929
  * contain usernames or secret-adjacent text and stays in daemon.log.
929
930
  */
930
931
  notifyInteractivePrompt(instanceName: string, kind: string): Promise<void>;
932
+ /**
933
+ * Offer a one-tap re-login next to an auth alert.
934
+ *
935
+ * The alert already names the remedy in words (`/login <backend>`), which
936
+ * still leaves the user to retype it somewhere. The button routes into the
937
+ * same chooser `/login` uses, so pressing it starts the flow in place.
938
+ *
939
+ * Backends with no remote login flow (opencode logs in from a terminal) get
940
+ * no button — the alert's own wording already tells them what to run.
941
+ */
942
+ offerBackendLogin(targetInstance: string, backend: string): Promise<void>;
931
943
  /** Consume a General assist button exactly once. */
932
944
  private handleInteractivePromptAssist;
933
945
  /** Send a "🛑 Cancel" button to the instance's topic/channel after delivery. */
@@ -1188,6 +1200,17 @@ export declare class FleetManager implements FleetContext, LifecycleContext, Arc
1188
1200
  * only re-reads credentials on process start (a paused instance's CLI is
1189
1201
  * already dead, so waking it respawns with the new token for free).
1190
1202
  */
1203
+ /**
1204
+ * Wake/restart every instance of a backend after a successful re-login.
1205
+ *
1206
+ * Bounded by a wall-clock deadline. This used to be an unbounded sequential
1207
+ * loop, and the caller only built its "login completed" message AFTER it
1208
+ * returned — so with several instances (or one slow restart) the user was
1209
+ * told nothing at all for minutes, concluded the login had hung, and
1210
+ * restarted things by hand. Instances that do not finish in time are NOT
1211
+ * cancelled: they are still coming back, and are reported as pending so the
1212
+ * message can say so instead of implying failure.
1213
+ */
1191
1214
  private recoverBackendInstances;
1192
1215
  /** Backend chooser button → start that backend's login session. */
1193
1216
  private handleLoginBackendSelect;
@@ -48,7 +48,8 @@ import { handleViewRequest, isViewPath } from "./view-api.js";
48
48
  import { handleUsageRequest, isUsagePath, usageProviderIdForBackend } from "./usage/usage-api.js";
49
49
  import { LOGIN_FLOWS, LOGIN_BACKEND_ALIASES, checkAuthStatus } from "./login-flows.js";
50
50
  import { LoginSession } from "./login-manager.js";
51
- import { LoginController, LOGIN_TOKEN_RESEND_PREFIX } from "./login-controller.js";
51
+ import { LoginController, LOGIN_TOKEN_RESEND_PREFIX, POST_LOGIN_RECOVERY_DEADLINE_MS, announcePostLoginRecovery } from "./login-controller.js";
52
+ import { runBeforeDeadline } from "./deadline.js";
52
53
  import { LoginWindowLock } from "./login-window-lock.js";
53
54
  import { handleSettingsRequest } from "./settings-api.js";
54
55
  import { setLocale, detectLocale, getLocale, t } from "./locale.js";
@@ -219,7 +220,7 @@ const HANG_CALLBACK_PREFIX = "hang:";
219
220
  const CLEAR_CONFIRM_CALLBACK_PREFIX = "clear-confirm:";
220
221
  const TIP_DISMISS_CALLBACK_PREFIX = "tip-dismiss:";
221
222
  const TIP_UNLOCK_CALLBACK_PREFIX = "tip-unlock:";
222
- const LOGIN_CALLBACK_PREFIX = "login:";
223
+ export const LOGIN_CALLBACK_PREFIX = "login:";
223
224
  const INSTALL_CALLBACK_PREFIX = "install-select:";
224
225
  const CLASSIC_APPROVE_CALLBACK_PREFIX = "classic-approve:";
225
226
  const LOGIN_MENU_CALLBACK_PREFIX = "login-menu:";
@@ -4785,7 +4786,9 @@ export class FleetManager {
4785
4786
  // The adapter resolves only after the platform POST returns. Keep
4786
4787
  // outward-facing logs/cancel state on the same confirmation boundary:
4787
4788
  // a routed-but-failed reply is not a delivered reply.
4788
- if (!error && result != null) {
4789
+ // `statusOnly` is daemon-owned envelope metadata, not an MCP argument:
4790
+ // an agent cannot invent it to suppress the normal completion marker.
4791
+ if (!error && result != null && msg.statusOnly !== true) {
4789
4792
  try {
4790
4793
  this.afterReplyRouted(instanceName, args, senderSessionName);
4791
4794
  }
@@ -6555,6 +6558,41 @@ export class FleetManager {
6555
6558
  extra: { generalName, promptKind: kind },
6556
6559
  });
6557
6560
  }
6561
+ /**
6562
+ * Offer a one-tap re-login next to an auth alert.
6563
+ *
6564
+ * The alert already names the remedy in words (`/login <backend>`), which
6565
+ * still leaves the user to retype it somewhere. The button routes into the
6566
+ * same chooser `/login` uses, so pressing it starts the flow in place.
6567
+ *
6568
+ * Backends with no remote login flow (opencode logs in from a terminal) get
6569
+ * no button — the alert's own wording already tells them what to run.
6570
+ */
6571
+ async offerBackendLogin(targetInstance, backend) {
6572
+ if (!LOGIN_FLOWS[backend])
6573
+ return;
6574
+ const adapterId = this.getInstanceAdapterId(targetInstance);
6575
+ const adapter = this.getAdapterForInstance(targetInstance);
6576
+ const chatId = this.getGroupIdForInstance(targetInstance);
6577
+ const topicId = this.fleetConfig?.instances[targetInstance]?.topic_id;
6578
+ const threadId = topicId != null ? String(topicId) : undefined;
6579
+ if (!adapter || !adapterId || !chatId) {
6580
+ this.logger.warn({ targetInstance, backend, adapterId, chatId }, "Cannot address the re-login button — the alert text still names the command");
6581
+ return;
6582
+ }
6583
+ await this.postNonceButtonPrompt({
6584
+ prefix: LOGIN_CALLBACK_PREFIX,
6585
+ alertType: "login",
6586
+ instanceName: "login",
6587
+ adapter,
6588
+ adapterId,
6589
+ chatId,
6590
+ threadId,
6591
+ message: t("login.offer", backend),
6592
+ choices: [{ action: backend, label: t("login.offer_action", backend) }],
6593
+ expiredText: t("buttons.stale"),
6594
+ });
6595
+ }
6558
6596
  /** Consume a General assist button exactly once. */
6559
6597
  async handleInteractivePromptAssist(data, callbackAdapterId, receivingAdapter) {
6560
6598
  const claimed = this.consumeNonceCallback(INTERACTIVE_ASSIST_CALLBACK_PREFIX, /^interactive-assist:([0-9a-f]+):(confirm|cancel)$/, data, callbackAdapterId, receivingAdapter);
@@ -7543,11 +7581,15 @@ export class FleetManager {
7543
7581
  if (cleanupFailed) {
7544
7582
  await chat.adapter.sendText(chat.chatId, t("login.web_cleanup_failed", backend), { threadId: chat.threadId }).catch(() => { });
7545
7583
  }
7584
+ const send = (text) => chat.adapter.sendText(chat.chatId, text, { threadId: chat.threadId }).catch(() => { });
7546
7585
  let text;
7547
7586
  if (ok) {
7548
- const { woken, restarted } = await this.recoverBackendInstances(backend);
7549
- const none = t("login.none");
7550
- text = t("login.success", backend, woken.length ? woken.join(", ") : none, restarted.length ? restarted.join(", ") : none);
7587
+ // Same order as the web-login path: the login result goes out first,
7588
+ // then the recovery reports its own outcome. Waiting for recovery to
7589
+ // build this message is what made a successful login look hung.
7590
+ await send(t("login.completed", backend));
7591
+ await announcePostLoginRecovery(backend, () => this.recoverBackendInstances(backend), send);
7592
+ return;
7551
7593
  }
7552
7594
  else if (detail === "cancelled") {
7553
7595
  // The cancel command's own reply already announced this — a second
@@ -7671,31 +7713,53 @@ export class FleetManager {
7671
7713
  * only re-reads credentials on process start (a paused instance's CLI is
7672
7714
  * already dead, so waking it respawns with the new token for free).
7673
7715
  */
7674
- async recoverBackendInstances(backend) {
7716
+ /**
7717
+ * Wake/restart every instance of a backend after a successful re-login.
7718
+ *
7719
+ * Bounded by a wall-clock deadline. This used to be an unbounded sequential
7720
+ * loop, and the caller only built its "login completed" message AFTER it
7721
+ * returned — so with several instances (or one slow restart) the user was
7722
+ * told nothing at all for minutes, concluded the login had hung, and
7723
+ * restarted things by hand. Instances that do not finish in time are NOT
7724
+ * cancelled: they are still coming back, and are reported as pending so the
7725
+ * message can say so instead of implying failure.
7726
+ */
7727
+ async recoverBackendInstances(backend, deadlineMs = POST_LOGIN_RECOVERY_DEADLINE_MS) {
7675
7728
  const woken = [];
7676
7729
  const restarted = [];
7730
+ const pending = [];
7731
+ const deadline = Date.now() + deadlineMs;
7677
7732
  for (const name of this.configuredBackendInstanceNames()) {
7678
7733
  if (this.backendNameOf(name) !== backend)
7679
7734
  continue;
7680
7735
  const status = this.getInstanceStatus(name);
7681
- try {
7736
+ if (status !== "paused" && status !== "running")
7737
+ continue;
7738
+ const result = await runBeforeDeadline(async () => {
7682
7739
  if (status === "paused") {
7683
7740
  if (this.daemons.has(name))
7684
7741
  await this.lifecycle.wake(name, 30_000);
7685
7742
  else
7686
7743
  await this.startPersistedPausedInstance(name);
7687
- woken.push(name);
7688
7744
  }
7689
- else if (status === "running") {
7745
+ else {
7690
7746
  await this.restartSingleInstance(name);
7691
- restarted.push(name);
7692
7747
  }
7748
+ }, deadline);
7749
+ if (result.status === "fulfilled") {
7750
+ (status === "paused" ? woken : restarted).push(name);
7693
7751
  }
7694
- catch (err) {
7695
- this.logger.warn({ err: err.message, name, status }, "Post-login recovery failed");
7752
+ else if (result.status === "timeout") {
7753
+ // Out of time: record it and stop waiting, but keep walking the list —
7754
+ // the remaining instances are checked against the same deadline and
7755
+ // fall straight through, so the caller still learns about all of them.
7756
+ pending.push(name);
7757
+ }
7758
+ else {
7759
+ this.logger.warn({ err: result.reason?.message, name, status }, "Post-login recovery failed");
7696
7760
  }
7697
7761
  }
7698
- return { woken, restarted };
7762
+ return { woken, restarted, pending };
7699
7763
  }
7700
7764
  /** Backend chooser button → start that backend's login session. */
7701
7765
  async handleLoginBackendSelect(data, callbackAdapterId, receivingAdapter) {