agent-yes 1.169.0 → 1.170.1

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.
Files changed (62) hide show
  1. package/dist/{SUPPORTED_CLIS-D1WUoZA9.js → SUPPORTED_CLIS-ByppVvWO.js} +2 -2
  2. package/dist/SUPPORTED_CLIS-C_c097RZ.js +8 -0
  3. package/dist/{agent-yes.config-Bl08kMpl.js → agent-yes.config-ugPYTPOd.js} +2 -2
  4. package/dist/{agentYesHome-_eJa3DaX.js → agentYesHome-CtHb5b71.js} +1 -1
  5. package/dist/cli.js +10 -212
  6. package/dist/{configShared-CEyhl0WH.js → configShared-0MnIQ652.js} +1 -1
  7. package/dist/{e2e-BeKjLhmO.js → e2e-jb0Hp43q.js} +1 -1
  8. package/dist/{forkNested-UCKPEgSI.js → forkNested-DhJxa4q4.js} +2 -2
  9. package/dist/{globalPidIndex-DlmmJlO8.js → globalPidIndex-CoNr7tS8.js} +1 -1
  10. package/dist/index.js +4 -4
  11. package/dist/notifyDaemon-D5jtpCAR.js +591 -0
  12. package/dist/{openBrowser-CCF1iuQK.js → openBrowser-ChR4llYa.js} +1 -1
  13. package/dist/{pidStore-BfoBWUjc.js → pidStore-BIvsBQ8X.js} +3 -3
  14. package/dist/pidStore-CLxwp0qI.js +5 -0
  15. package/dist/{reaper-C-eWAxIj.js → reaper-CWF2_ATd.js} +27 -3
  16. package/dist/{reaper-BQgDb85W.js → reaper-jfSmog15.js} +1 -1
  17. package/dist/{remotes-cx_GDFPj.js → remotes-BQMr4_En.js} +3 -3
  18. package/dist/{remotes-BmAPylU_.js → remotes-CC-4GuJb.js} +3 -3
  19. package/dist/{runningLock-BobVW1_A.js → runningLock-CNMl13dC.js} +1 -1
  20. package/dist/rustBinary-DsO0IlNq.js +228 -0
  21. package/dist/{schedule-DTHFMzDj.js → schedule-ChTPIcXf.js} +7 -7
  22. package/dist/{serve-D-joIHrL.js → serve-De1gAWNI.js} +120 -26
  23. package/dist/{setup-K6VTmirE.js → setup-nlMqyEPB.js} +3 -3
  24. package/dist/{share-D_e2Fwiy.js → share-QregR8a_.js} +2 -2
  25. package/dist/spawnGate-BFhva-2F.js +5 -0
  26. package/dist/{spawnGate-DzPfa1PZ.js → spawnGate-IJDByl2U.js} +3 -3
  27. package/dist/{subcommands-BZPW0UVn.js → subcommands-BFlpV23b.js} +1049 -44
  28. package/dist/subcommands-DLDpwrfG.js +9 -0
  29. package/dist/{tray-CWUpaZF4.js → tray-DXr7iK3E.js} +2 -2
  30. package/dist/{ts-DeahYDN3.js → ts-DsxejviJ.js} +10 -10
  31. package/dist/{versionChecker-C7KP-aoh.js → versionChecker-Czk3aHqy.js} +2 -2
  32. package/dist/{webrtcLink-BG0Xc4-W.js → webrtcLink-B7REGtK2.js} +2 -2
  33. package/dist/{webrtcRemote-SybKurg9.js → webrtcRemote-Bx-eD_0I.js} +3 -3
  34. package/dist/{workspaceConfig-BC03X4Y1.js → workspaceConfig-BgqK-31W.js} +37 -6
  35. package/lab/ui/console-logic.js +79 -10
  36. package/lab/ui/index.html +174 -8
  37. package/lab/ui/landing.html +11 -1
  38. package/package.json +1 -1
  39. package/scripts/build-rs.ts +9 -5
  40. package/ts/badges.spec.ts +51 -1
  41. package/ts/badges.ts +35 -1
  42. package/ts/index.ts +13 -4
  43. package/ts/notifyDaemon.spec.ts +276 -0
  44. package/ts/notifyDaemon.ts +698 -0
  45. package/ts/notifyInbox.spec.ts +192 -0
  46. package/ts/notifyInbox.ts +294 -0
  47. package/ts/notifyRouter.spec.ts +355 -0
  48. package/ts/notifyRouter.ts +288 -0
  49. package/ts/notifyStore.spec.ts +308 -0
  50. package/ts/notifyStore.ts +439 -0
  51. package/ts/reaper.spec.ts +18 -1
  52. package/ts/reaper.ts +33 -1
  53. package/ts/rustBinary.ts +30 -0
  54. package/ts/serve.ts +180 -14
  55. package/ts/subcommands.spec.ts +341 -0
  56. package/ts/subcommands.ts +764 -29
  57. package/ts/workspaceConfig.spec.ts +48 -0
  58. package/ts/workspaceConfig.ts +46 -3
  59. package/dist/SUPPORTED_CLIS-BFV10E3f.js +0 -8
  60. package/dist/pidStore-5qyLX3Fh.js +0 -5
  61. package/dist/spawnGate-CQ1Il3Xk.js +0 -5
  62. package/dist/subcommands-DuGwtmz5.js +0 -9
@@ -0,0 +1,9 @@
1
+ import "./logger-CDIsZ-Pp.js";
2
+ import "./globalPidIndex-CoNr7tS8.js";
3
+ import "./configShared-0MnIQ652.js";
4
+ import { A as resolveOne, B as writeKeysPaced, C as matchKeyword, D as renderLogTailLines, E as readPtysize, F as snapshotStatus, I as stdinActivityPath, L as stopTipForCli, M as resolveResumeArgs, N as restartHintLines, O as renderRawLog, P as runSubcommand, R as submitAndConfirm, S as listRecords, T as readNotes, V as writeToIpc, _ as isPidAlive, a as cmdHelp, b as isUserTyping, c as deriveLiveState, d as extractMenu, f as extractNeedsInput, g as isExitRequest, h as isAgentStuck, i as backoffWhileTyping, j as resolveReadWindow, k as renderRawLogLines, l as deriveLiveStatus, m as finalizedLines, n as READ_PAGE_DEFAULT, o as controlCodeFromName, p as extractTaskCounts, r as TYPING_WINDOW_MS, s as cursorAbs, t as GRACEFUL_EXIT_COMMANDS, u as extractBadges, v as isSlashCommand, w as menuSelectKeys, x as lastStdinAt, y as isSubcommand, z as waitForLogQuiet } from "./subcommands-BFlpV23b.js";
5
+ import "./e2e-jb0Hp43q.js";
6
+ import "./webrtcLink-B7REGtK2.js";
7
+ import "./remotes-CC-4GuJb.js";
8
+
9
+ export { cmdHelp, isSubcommand, runSubcommand };
@@ -1,4 +1,4 @@
1
- import { n as getRunningAgentCount } from "./runningLock-BobVW1_A.js";
1
+ import { n as getRunningAgentCount } from "./runningLock-CNMl13dC.js";
2
2
  import { existsSync } from "fs";
3
3
  import { mkdir, readFile, unlink, writeFile } from "fs/promises";
4
4
  import { homedir } from "os";
@@ -187,4 +187,4 @@ async function startTray() {
187
187
 
188
188
  //#endregion
189
189
  export { ensureTray, startTray };
190
- //# sourceMappingURL=tray-CWUpaZF4.js.map
190
+ //# sourceMappingURL=tray-DXr7iK3E.js.map
@@ -1,10 +1,10 @@
1
1
  import { n as logger, t as addTransport } from "./logger-CDIsZ-Pp.js";
2
- import { r as getInstalledPackage } from "./versionChecker-C7KP-aoh.js";
3
- import { t as agentYesHome } from "./agentYesHome-_eJa3DaX.js";
4
- import { i as shouldUseLock, r as releaseLock, t as acquireLock } from "./runningLock-BobVW1_A.js";
5
- import { t as PidStore } from "./pidStore-BfoBWUjc.js";
6
- import { i as readGlobalPids } from "./globalPidIndex-DlmmJlO8.js";
7
- import { n as register, r as sweep } from "./reaper-C-eWAxIj.js";
2
+ import { r as getInstalledPackage } from "./versionChecker-Czk3aHqy.js";
3
+ import { t as agentYesHome } from "./agentYesHome-CtHb5b71.js";
4
+ import { i as shouldUseLock, r as releaseLock, t as acquireLock } from "./runningLock-CNMl13dC.js";
5
+ import { t as PidStore } from "./pidStore-BIvsBQ8X.js";
6
+ import { i as readGlobalPids } from "./globalPidIndex-CoNr7tS8.js";
7
+ import { n as register, r as sweep } from "./reaper-CWF2_ATd.js";
8
8
  import { arch, platform } from "process";
9
9
  import { execSync } from "child_process";
10
10
  import { closeSync, constants, createReadStream, existsSync, mkdirSync, openSync } from "fs";
@@ -1084,7 +1084,7 @@ async function notifyWebhook(status, details, cwd = process.cwd()) {
1084
1084
 
1085
1085
  //#endregion
1086
1086
  //#region ts/index.ts
1087
- const config = await import("./agent-yes.config-Bl08kMpl.js").then((mod) => mod.default || mod);
1087
+ const config = await import("./agent-yes.config-ugPYTPOd.js").then((mod) => mod.default || mod);
1088
1088
  const CLIS_CONFIG = config.clis;
1089
1089
  /**
1090
1090
  * Main function to run agent-cli with automatic yes/no responses
@@ -1579,7 +1579,7 @@ async function agentYes({ cli, cliArgs = [], skipPermissions = false, prompt, ro
1579
1579
  } catch (error) {
1580
1580
  logger.debug(`heartbeat|error: ${error}`);
1581
1581
  }
1582
- }, 800);
1582
+ }, 100);
1583
1583
  const cleanupHeartbeat = () => clearInterval(heartbeatInterval);
1584
1584
  shell.onExit(cleanupHeartbeat);
1585
1585
  if (exitOnIdle) (async () => {
@@ -1805,7 +1805,7 @@ async function agentYes({ cli, cliArgs = [], skipPermissions = false, prompt, ro
1805
1805
  if (exited) return;
1806
1806
  shell.kill();
1807
1807
  resolve();
1808
- }, 5e3))]);
1808
+ }, 2e3))]);
1809
1809
  }
1810
1810
  function getTerminalDimensions() {
1811
1811
  if (!process.stdout.isTTY) return {
@@ -1824,4 +1824,4 @@ function sleep(ms) {
1824
1824
 
1825
1825
  //#endregion
1826
1826
  export { removeControlCharacters as a, AgentContext as i, agentYes as n, config as r, CLIS_CONFIG as t };
1827
- //# sourceMappingURL=ts-DeahYDN3.js.map
1827
+ //# sourceMappingURL=ts-DsxejviJ.js.map
@@ -7,7 +7,7 @@ import { fileURLToPath } from "url";
7
7
 
8
8
  //#region package.json
9
9
  var name = "agent-yes";
10
- var version = "1.169.0";
10
+ var version = "1.170.1";
11
11
 
12
12
  //#endregion
13
13
  //#region ts/versionChecker.ts
@@ -215,4 +215,4 @@ async function displayVersion() {
215
215
 
216
216
  //#endregion
217
217
  export { versionString as i, displayVersion as n, getInstalledPackage as r, checkAndAutoUpdate as t };
218
- //# sourceMappingURL=versionChecker-C7KP-aoh.js.map
218
+ //# sourceMappingURL=versionChecker-Czk3aHqy.js.map
@@ -1,4 +1,4 @@
1
- import { u as parseSecret } from "./e2e-BeKjLhmO.js";
1
+ import { u as parseSecret } from "./e2e-jb0Hp43q.js";
2
2
 
3
3
  //#region ts/webrtcLink.ts
4
4
  const SIGNAL_SUBPROTOCOL = "ay-signal-1";
@@ -42,4 +42,4 @@ function isWebrtcSpec(spec) {
42
42
 
43
43
  //#endregion
44
44
  export { isWebrtcSpec as n, parseWebrtcLink as r, SIGNAL_SUBPROTOCOL as t };
45
- //# sourceMappingURL=webrtcLink-BG0Xc4-W.js.map
45
+ //# sourceMappingURL=webrtcLink-B7REGtK2.js.map
@@ -1,5 +1,5 @@
1
- import { a as computeTranscriptHash, c as open, d as randomHex, f as seal, l as packEnvelope, n as FLAG_CONFIRM, o as deriveAuthToken, p as unpackEnvelope, s as deriveDirKeys } from "./e2e-BeKjLhmO.js";
2
- import { n as isWebrtcSpec, r as parseWebrtcLink, t as SIGNAL_SUBPROTOCOL } from "./webrtcLink-BG0Xc4-W.js";
1
+ import { a as computeTranscriptHash, c as open, d as randomHex, f as seal, l as packEnvelope, n as FLAG_CONFIRM, o as deriveAuthToken, p as unpackEnvelope, s as deriveDirKeys } from "./e2e-jb0Hp43q.js";
2
+ import { n as isWebrtcSpec, r as parseWebrtcLink, t as SIGNAL_SUBPROTOCOL } from "./webrtcLink-B7REGtK2.js";
3
3
  import { RTCPeerConnection } from "node-datachannel/polyfill";
4
4
 
5
5
  //#region ts/webrtcRemote.ts
@@ -256,4 +256,4 @@ async function startWebrtcBridge(link) {
256
256
 
257
257
  //#endregion
258
258
  export { startWebrtcBridge };
259
- //# sourceMappingURL=webrtcRemote-SybKurg9.js.map
259
+ //# sourceMappingURL=webrtcRemote-Bx-eD_0I.js.map
@@ -1,4 +1,4 @@
1
- import { t as agentYesHome } from "./agentYesHome-_eJa3DaX.js";
1
+ import { t as agentYesHome } from "./agentYesHome-CtHb5b71.js";
2
2
  import { lstatSync, mkdirSync, readFileSync, statSync, writeFileSync } from "fs";
3
3
  import { homedir } from "os";
4
4
  import path from "path";
@@ -80,8 +80,17 @@ function isProvisionAllowed(owner, repo) {
80
80
  function getSpawnHook() {
81
81
  const env = process.env.AGENT_YES_SPAWN_HOOK;
82
82
  if (env && env.trim()) return env;
83
- const h = readConfig().spawnHook;
84
- if (!h || !h.trim()) return null;
83
+ return configHookIfTrusted(readConfig().spawnHook);
84
+ }
85
+ /**
86
+ * Return a config-file hook value only when the config file is safe to trust as
87
+ * a source of code to run: not a symlink (can't be swapped out from under us),
88
+ * owned by us, and not group/world-writable (no other user can rewrite it). The
89
+ * env-var forms of these hooks bypass this — they come from the daemon's own
90
+ * environment, which is already trusted. Returns null when unset or guarded out.
91
+ */
92
+ function configHookIfTrusted(value) {
93
+ if (!value || !value.trim()) return null;
85
94
  if (process.platform !== "win32") try {
86
95
  if (lstatSync(configPath()).isSymbolicLink()) return null;
87
96
  const st = statSync(configPath());
@@ -90,13 +99,35 @@ function getSpawnHook() {
90
99
  } catch {
91
100
  return null;
92
101
  }
93
- return h;
102
+ return value;
94
103
  }
95
104
  /** Whether a usable {@link getSpawnHook} is configured (for read-only disclosure). */
96
105
  function hasSpawnHook() {
97
106
  return getSpawnHook() !== null;
98
107
  }
99
108
  /**
109
+ * A trusted, HOST-LOCAL shell hook run BEFORE a `fork`/`from` provisioning git
110
+ * op — "koho-style" (codehost-style) provisioning. Env `AGENT_YES_PROVISION_HOOK`
111
+ * overrides the config `provisionHook`; the config form is subject to the same
112
+ * tamper guard as {@link getSpawnHook} ({@link configHookIfTrusted}).
113
+ *
114
+ * Its purpose is to prepare the host for provisioning — most usefully to select
115
+ * the right git identity (e.g. `gh auth switch --user <who>`, keyed on the
116
+ * `KOHO_OWNER`/`KOHO_REPO` env the caller exports) before the clone/worktree/
117
+ * setup runs. When configured it is ALSO the provisioning gate: its exit code
118
+ * decides admission (0 = allow, non-zero = deny), overriding
119
+ * {@link getProvisionAllowlist}. Returns null when unset/guarded.
120
+ */
121
+ function getProvisionHook() {
122
+ const env = process.env.AGENT_YES_PROVISION_HOOK;
123
+ if (env && env.trim()) return env;
124
+ return configHookIfTrusted(readConfig().provisionHook);
125
+ }
126
+ /** Whether a usable {@link getProvisionHook} is configured (for read-only disclosure). */
127
+ function hasProvisionHook() {
128
+ return getProvisionHook() !== null;
129
+ }
130
+ /**
100
131
  * Cap on concurrently-live agents admitted via `/api/spawn`. Env
101
132
  * `AGENT_YES_MAX_AGENTS` overrides the config `maxAgents`. A non-positive,
102
133
  * missing, or unparseable value means **unlimited** (returns undefined), which
@@ -161,5 +192,5 @@ function resolveSpawnCwd(input) {
161
192
  }
162
193
 
163
194
  //#endregion
164
- export { getSpawnWaitMs as a, isProvisionAllowed as c, getSpawnHook as i, resolveSpawnCwd as l, getMinFreeMb as n, getWorkspaceRoot as o, getProvisionRoot as r, hasSpawnHook as s, getMaxAgents as t, setWorkspaceRoot as u };
165
- //# sourceMappingURL=workspaceConfig-BC03X4Y1.js.map
195
+ export { getSpawnHook as a, hasProvisionHook as c, resolveSpawnCwd as d, setWorkspaceRoot as f, getProvisionRoot as i, hasSpawnHook as l, getMinFreeMb as n, getSpawnWaitMs as o, getProvisionHook as r, getWorkspaceRoot as s, getMaxAgents as t, isProvisionAllowed as u };
196
+ //# sourceMappingURL=workspaceConfig-BgqK-31W.js.map
@@ -187,6 +187,18 @@ export function taskLabel(e) {
187
187
  // id), so a newly-added server-side badge is never silently dropped.
188
188
  export const BADGE_META = {
189
189
  "goal-active": { label: "goal", title: "A /goal Stop-hook loop is active on this agent" },
190
+ "session-limit": {
191
+ label: "limit",
192
+ title: "Usage session limit hit — waiting for the reset time shown on screen",
193
+ },
194
+ retrying: {
195
+ label: "retry",
196
+ title: "Waiting for the API — the CLI is auto-retrying on its own backoff (no action needed)",
197
+ },
198
+ typing: {
199
+ label: "typing",
200
+ title: "The user is typing at this agent's terminal — ay send backs off until they pause",
201
+ },
190
202
  };
191
203
 
192
204
  // Status-flag chips ("badges") matched against the agent's screen — e.g. an
@@ -196,11 +208,16 @@ export function badgesFor(e) {
196
208
  return (e.badges || []).map((id) => ({ id, ...(BADGE_META[id] || { label: id, title: id }) }));
197
209
  }
198
210
 
199
- // Human age of an agent ("12s" / "5m" / "3h"). `now` is injectable so tests
200
- // don't depend on the wall clock; the browser calls age(e) and gets Date.now().
211
+ // Time since the agent was last active ("12s" / "5m" / "3h") measured from
212
+ // its last stdout write (last_active_at, the log file's mtime), so a long-lived
213
+ // but quiet agent reads as stale rather than "new". Falls back to started_at
214
+ // when the server hasn't stamped a last-active time (e.g. freshly spawned, no
215
+ // log yet). `now` is injectable so tests don't depend on the wall clock; the
216
+ // browser calls age(e) and gets Date.now().
201
217
  export function age(e, now = Date.now()) {
202
- if (!e.started_at) return "";
203
- const s = Math.max(0, (now - e.started_at) / 1000);
218
+ const at = e.last_active_at ?? e.started_at;
219
+ if (!at) return "";
220
+ const s = Math.max(0, (now - at) / 1000);
204
221
  if (s < 60) return Math.floor(s) + "s";
205
222
  if (s < 3600) return Math.floor(s / 60) + "m";
206
223
  return Math.floor(s / 3600) + "h";
@@ -331,7 +348,7 @@ function agentForestNodes(list) {
331
348
  // flat entry list BEFORE layeredRows builds the room/peer/agent tree (which keeps
332
349
  // the given order for siblings and first-seen order for room/peer groups). So
333
350
  // sorting reorders roots and siblings without breaking the nesting.
334
- export const SORT_MODES = ["state", "created", "identity"];
351
+ export const SORT_MODES = ["state", "active", "created", "identity"];
335
352
 
336
353
  // Attention-first state ranking: someone scanning the fleet wants the agents that
337
354
  // need them (needs_input) up top, then the wedged ones (stuck), then live work,
@@ -358,19 +375,29 @@ function gitWeight(e) {
358
375
  return (g.changed || 0) + (g.ahead || 0) + (g.behind || 0) + (g.dirty ? 0.5 : 0);
359
376
  }
360
377
 
378
+ // Last-active instant for an entry: its last stdout write (last_active_at),
379
+ // falling back to started_at when the server hasn't stamped one yet.
380
+ function lastActive(e) {
381
+ return e.last_active_at ?? e.started_at ?? 0;
382
+ }
383
+
361
384
  // Return a NEW array sorted for display per `mode` (default "state"):
362
385
  // - "state": attention-first state, then git busyness, then newest.
386
+ // - "active": most recently active first (last_active_at desc).
363
387
  // - "created": newest first (started_at desc).
364
388
  // - "identity": user@host:owner/repo/branch (alphabetical).
365
389
  // Every comparator falls back to newest-first so order is total & deterministic.
366
390
  export function sortEntries(entries, mode = "state") {
367
391
  const byNewest = (a, b) => (b.started_at || 0) - (a.started_at || 0);
392
+ const byActive = (a, b) => lastActive(b) - lastActive(a) || byNewest(a, b);
368
393
  const cmp =
369
- mode === "created"
370
- ? byNewest
371
- : mode === "identity"
372
- ? (a, b) => fullIdent(a).localeCompare(fullIdent(b)) || byNewest(a, b)
373
- : (a, b) => stateRank(a) - stateRank(b) || gitWeight(b) - gitWeight(a) || byNewest(a, b);
394
+ mode === "active"
395
+ ? byActive
396
+ : mode === "created"
397
+ ? byNewest
398
+ : mode === "identity"
399
+ ? (a, b) => fullIdent(a).localeCompare(fullIdent(b)) || byNewest(a, b)
400
+ : (a, b) => stateRank(a) - stateRank(b) || gitWeight(b) - gitWeight(a) || byNewest(a, b);
374
401
  return entries.slice().sort(cmp);
375
402
  }
376
403
 
@@ -465,6 +492,48 @@ function toAgentNode(n) {
465
492
  return { kind: "agent", entry: n.entry, children: n.children.map(toAgentNode) };
466
493
  }
467
494
 
495
+ // When the subagent trees are folded away, each surviving root agent shows a
496
+ // summary chip of the descendants hidden beneath it. Given the FULL layeredRows
497
+ // output, returns a Map keyed by the root agent's `entry` →
498
+ // { total, working, lastActive }:
499
+ // total = alive (non-exited) descendant subagents, at every depth
500
+ // working = those whose status is "active"
501
+ // lastActive = newest last_active_at (fallback started_at) among the alive
502
+ // descendants, or null when none is stamped
503
+ // Descendants roll up to their nearest folded-away ancestor's root, so a folded
504
+ // tree summarizes its whole subtree in one chip. Roots with no alive descendant
505
+ // aren't in the map (no chip). Pure so tests can exercise it without a DOM.
506
+ export function foldSummaries(rows) {
507
+ // Direct parent-agent entry → child agent entries (skip room/peer headers).
508
+ const kids = new Map();
509
+ for (const r of rows) {
510
+ if (r.kind !== "agent" || !r.parentEntry) continue;
511
+ const arr = kids.get(r.parentEntry) || [];
512
+ arr.push(r.entry);
513
+ kids.set(r.parentEntry, arr);
514
+ }
515
+ const summaries = new Map();
516
+ for (const r of rows) {
517
+ if (r.kind !== "agent" || r.parentEntry) continue; // roots only
518
+ const acc = { total: 0, working: 0, lastActive: null };
519
+ const stack = [...(kids.get(r.entry) || [])];
520
+ const seen = new Set();
521
+ while (stack.length) {
522
+ const e = stack.pop();
523
+ if (seen.has(e)) continue; // guard a pathological parent cycle
524
+ seen.add(e);
525
+ for (const c of kids.get(e) || []) stack.push(c);
526
+ if (e.status === "exited") continue;
527
+ acc.total++;
528
+ if (e.status === "active") acc.working++;
529
+ const at = e.last_active_at ?? e.started_at;
530
+ if (at != null && (acc.lastActive == null || at > acc.lastActive)) acc.lastActive = at;
531
+ }
532
+ if (acc.total > 0) summaries.set(r.entry, acc);
533
+ }
534
+ return summaries;
535
+ }
536
+
468
537
  // Next selection index when stepping the list by `dir` (+1 down / -1 up).
469
538
  // No current selection (i<0) lands on the first (down) or last (up) row;
470
539
  // otherwise clamps at the ends. Returns -1 for an empty list.
package/lab/ui/index.html CHANGED
@@ -630,6 +630,10 @@
630
630
  .omni-spawn .omni-title {
631
631
  color: var(--green);
632
632
  }
633
+ /* Destructive slash command (/kill) — red title, matching the ⋯-menu Force-kill. */
634
+ .omni-spawn.danger .omni-title {
635
+ color: var(--red);
636
+ }
633
637
  .omni-empty {
634
638
  padding: 14px;
635
639
  color: var(--muted);
@@ -891,6 +895,26 @@
891
895
  color: var(--green);
892
896
  border-color: #2a3a2a;
893
897
  }
898
+ /* Folded-subagent summary chip ("⊞ 3/5" = 3 working of 5 hidden subagents),
899
+ shown on a root agent row when the subagent trees are collapsed. Same pill
900
+ shape as .tasks; the ⊞ glyph echoes the fold toolbar button. Turns accent
901
+ when any hidden subagent is still working, so a collapsed tree with live
902
+ children stands out from a quiet one. Hover for the count + last-active. */
903
+ .subs {
904
+ font-family: var(--mono);
905
+ font-size: 10.5px;
906
+ color: var(--muted);
907
+ white-space: nowrap;
908
+ flex: none;
909
+ padding: 0 5px;
910
+ border: 1px solid var(--line);
911
+ border-radius: 8px;
912
+ cursor: pointer;
913
+ }
914
+ .subs.active {
915
+ color: var(--accent);
916
+ border-color: var(--accent);
917
+ }
894
918
  /* Status-flag chip ("badge") matched server-side against the agent's
895
919
  screen — e.g. an active /goal loop (ts/badges.ts). Same pill shape as
896
920
  .tasks but accent-colored so a matched flag stands out as a distinct
@@ -1370,6 +1394,7 @@
1370
1394
  <div class="meta">
1371
1395
  <span id="count"></span>
1372
1396
  <span class="metaright">
1397
+ <button id="foldbtn" class="viewbtn" title="fold subagent trees">⊞ subs</button>
1373
1398
  <button id="sortbtn" class="viewbtn" title="cycle sort order">⇅ state</button>
1374
1399
  <button id="viewbtn" class="viewbtn" title="toggle compact list">☰</button>
1375
1400
  <button id="newbtn" class="newbtn" title="spawn a new agent on this fleet">
@@ -1554,6 +1579,7 @@
1554
1579
  hasIdent,
1555
1580
  deviceCount,
1556
1581
  layeredRows,
1582
+ foldSummaries,
1557
1583
  sortEntries,
1558
1584
  SORT_MODES,
1559
1585
  taskLabel,
@@ -2819,8 +2845,10 @@
2819
2845
  // Force — escalation a stuck agent can't ignore: a real SIGKILL of its process
2820
2846
  // GROUP, server-side via POST /api/kill. Use when /exit does nothing.
2821
2847
  const GRACEFUL_EXIT = { claude: "/exit", codex: "/exit", gemini: "/quit" };
2822
- async function stopAgent(force) {
2823
- const e = sel ? entries.find((x) => x._key === sel) : null;
2848
+ // `ent` targets a specific agent (the Cmd+K /kill · /restart commands pass the
2849
+ // omnibox anchor); the ⋯-menu buttons omit it and act on the selected agent.
2850
+ async function stopAgent(force, ent) {
2851
+ const e = ent || (sel ? entries.find((x) => x._key === sel) : null);
2824
2852
  if (!e) return;
2825
2853
  const who = `pid ${e.pid} ${cliLabel(e) || ident(e) || ""}`.trim();
2826
2854
  const tx = txFor(e),
@@ -2843,8 +2871,8 @@
2843
2871
  // Restart — stop (if live) + relaunch resuming the session, server-side via
2844
2872
  // POST /api/restart (the `ay restart` flow). The server runs it detached, so
2845
2873
  // it's decoupled from this agent's lifecycle — no prompt typed into the agent.
2846
- async function restartAgent() {
2847
- const e = sel ? entries.find((x) => x._key === sel) : null;
2874
+ async function restartAgent(ent) {
2875
+ const e = ent || (sel ? entries.find((x) => x._key === sel) : null);
2848
2876
  if (!e) return;
2849
2877
  const who = `pid ${e.pid} ${cliLabel(e) || ident(e) || ""}`.trim();
2850
2878
  if (!confirm(`Restart ${who}?\nStops it (if live) and relaunches resuming the session.`))
@@ -3191,6 +3219,14 @@
3191
3219
  // Compact list: one line per agent (dot + cli + title), persisted per device.
3192
3220
  let compactList = localStorage.getItem("ay.compactList") === "1";
3193
3221
 
3222
+ // Fold subagent trees: hide nested (sub)agent rows and roll each root's
3223
+ // hidden descendants into a summary chip. Folded BY DEFAULT (only "0" opts
3224
+ // out) so a busy fleet reads as a flat list of top-level agents at a glance.
3225
+ let foldSubs = localStorage.getItem("ay.foldSubs") !== "0";
3226
+ // Per-render Map(rootEntry → { total, working, lastActive }); rebuilt by
3227
+ // renderList whenever folding is on, consumed by foldChipHtml.
3228
+ let foldSum = null;
3229
+
3194
3230
  // Display sort order, cycled by the sort button and persisted per device.
3195
3231
  // Default "state" (attention-first state, then git busyness). Guard against
3196
3232
  // a stale/garbage stored value so we never start in an unknown mode.
@@ -3234,6 +3270,21 @@
3234
3270
  return `<span class="tasks${allDone ? " done" : ""}" title="${esc(tip)}">${esc(label)}</span>`;
3235
3271
  }
3236
3272
 
3273
+ // Folded-subagent summary chip ("⊞ 3/5") for a root agent whose subagent
3274
+ // tree is collapsed — working/total of its hidden descendants, from the
3275
+ // per-render foldSum map. "" when folding is off or this row has no hidden
3276
+ // subagents. Clicking it expands the trees (see the list click handler).
3277
+ // The tooltip carries the recency the badge face omits ("last active 5s ago").
3278
+ function foldChipHtml(e) {
3279
+ const s = foldSum && foldSum.get(e);
3280
+ if (!s) return "";
3281
+ const rel = s.lastActive != null ? age({ last_active_at: s.lastActive }) : "";
3282
+ const tip =
3283
+ `${s.total} subagent${s.total === 1 ? "" : "s"} folded · ${s.working} working` +
3284
+ (rel ? ` · last active ${rel} ago` : "");
3285
+ return `<span class="subs${s.working ? " active" : ""}" data-fold-chip="1" title="${esc(tip)}">⊞ ${s.working}/${s.total}</span>`;
3286
+ }
3287
+
3237
3288
  // Status-flag chips ("badges") matched server-side against the agent's
3238
3289
  // screen (ts/badges.ts) — e.g. an active /goal loop. One small pill per
3239
3290
  // matched flag; "" when none matched. CSS class is "flag" (not "badge")
@@ -3264,9 +3315,18 @@
3264
3315
  const toks = $("q").value.trim().split(/\s+/).filter(Boolean);
3265
3316
  // Build the full signalling-server > rooms > peers > agents > subagents
3266
3317
  // tree. Single-node room/peer layers fold away; ≥2 become ├ └ │ branches.
3267
- const rows = layeredRows(orderedEntries(toks));
3268
- const agentRows = rows.filter((r) => r.kind === "agent");
3318
+ const fullRows = layeredRows(orderedEntries(toks));
3319
+ const agentRows = fullRows.filter((r) => r.kind === "agent");
3320
+ // Fold: summarize each root's hidden descendants, then drop subagent rows
3321
+ // (any agent row with a parent agent). The count still reports every
3322
+ // matching agent, so folding never makes the fleet look smaller.
3323
+ foldSum = foldSubs ? foldSummaries(fullRows) : null;
3324
+ const rows = foldSubs
3325
+ ? fullRows.filter((r) => !(r.kind === "agent" && r.parentEntry))
3326
+ : fullRows;
3269
3327
  $("count").textContent = `${agentRows.length} / ${entries.length} agents`;
3328
+ $("foldbtn").textContent = foldSubs ? "⊞ subs" : "⊟ subs";
3329
+ $("foldbtn").classList.toggle("on", !foldSubs);
3270
3330
  $("viewbtn").classList.toggle("on", compactList);
3271
3331
  // Show the active sort mode on the button so it's self-documenting; the
3272
3332
  // default ("state") is also highlighted to hint it's the implicit order.
@@ -3293,6 +3353,7 @@
3293
3353
  ${hasIdent(id) ? `<span class="cident" title="${esc(fullIdent(e))}">${esc(id)}</span>` : ""}
3294
3354
  ${cli ? `<span class="cname">${esc(cli)}</span>` : ""}
3295
3355
  <span class="ctitle ${e.title ? "" : "dim"}" title="${esc(t)}">${esc(t)}</span>
3356
+ ${foldChipHtml(e)}
3296
3357
  ${taskChipHtml(e)}
3297
3358
  ${badgeChipsHtml(e)}
3298
3359
  ${gitChipHtml(e)}
@@ -3319,6 +3380,7 @@
3319
3380
  <div class="r1">${r.branch ? `<span class="tbranch">${esc(r.branch)}</span>` : ""}<span class="dot ${esc(e.status)}"></span>
3320
3381
  <span class="name">${esc(cliLabel(e) || ident(e) || "agent")}</span>
3321
3382
  <span class="badge">pid ${e.pid}</span>
3383
+ ${foldChipHtml(e)}
3322
3384
  ${taskChipHtml(e)}
3323
3385
  ${badgeChipsHtml(e)}
3324
3386
  ${gitChipHtml(e)}
@@ -3680,6 +3742,14 @@
3680
3742
  }
3681
3743
 
3682
3744
  $("list").addEventListener("click", (ev) => {
3745
+ // Clicking a folded-subagent chip expands the trees instead of selecting
3746
+ // the row, so the hidden children it summarizes become reachable.
3747
+ if (ev.target.closest("[data-fold-chip]")) {
3748
+ foldSubs = false;
3749
+ localStorage.setItem("ay.foldSubs", "0");
3750
+ renderList();
3751
+ return;
3752
+ }
3683
3753
  const row = ev.target.closest(".row");
3684
3754
  if (row) select(row.dataset.key);
3685
3755
  });
@@ -3739,6 +3809,12 @@
3739
3809
  localStorage.setItem("ay.compactList", compactList ? "1" : "0");
3740
3810
  renderList();
3741
3811
  });
3812
+ // Fold / unfold every subagent tree (global toggle, persisted per device).
3813
+ $("foldbtn").addEventListener("click", () => {
3814
+ foldSubs = !foldSubs;
3815
+ localStorage.setItem("ay.foldSubs", foldSubs ? "1" : "0");
3816
+ renderList();
3817
+ });
3742
3818
  // Cycle the sort order: state → created → identity → state.
3743
3819
  $("sortbtn").addEventListener("click", () => {
3744
3820
  sortMode = SORT_MODES[(SORT_MODES.indexOf(sortMode) + 1) % SORT_MODES.length];
@@ -3781,9 +3857,10 @@
3781
3857
  // renders. Clamps at the ends, scrolls the row into view.
3782
3858
  function stepSelection(dir) {
3783
3859
  const toks = $("q").value.trim().split(/\s+/).filter(Boolean);
3784
- // Must match renderList's order; step only over agent rows (skip headers).
3860
+ // Must match renderList's order; step only over agent rows (skip headers),
3861
+ // and skip folded-away subagents so nav lands only on visible rows.
3785
3862
  const shown = layeredRows(orderedEntries(toks))
3786
- .filter((r) => r.kind === "agent")
3863
+ .filter((r) => r.kind === "agent" && !(foldSubs && r.parentEntry))
3787
3864
  .map((r) => r.entry);
3788
3865
  if (!shown.length) return;
3789
3866
  const cur = shown.findIndex((e) => e._key === sel);
@@ -3889,6 +3966,66 @@
3889
3966
  return cur && cur.cwd && cur.git && cur.git.branch ? cur : null;
3890
3967
  }
3891
3968
 
3969
+ // Slash commands that act on the OPEN agent (the omnibox anchor), mirroring the
3970
+ // ⋯-menu recovery buttons. Typing "/" in the omnibox lists them; "/k" / "/r"
3971
+ // narrows. Each runs the very same handler the dropdown button does, so the
3972
+ // confirm prompt and server call are identical — /kill = Force-kill (SIGKILL),
3973
+ // /restart = Restart (resume). "/fork [topic]" is also offered here (built in
3974
+ // omniCommandRows, not this list, since it spawns rather than acts in place).
3975
+ const OMNI_COMMANDS = [
3976
+ {
3977
+ cmd: "/restart",
3978
+ icon: "↻",
3979
+ title: "Restart (resume)",
3980
+ desc: "stops it (if live) and relaunches resuming the session",
3981
+ run: (e) => restartAgent(e),
3982
+ },
3983
+ {
3984
+ cmd: "/kill",
3985
+ icon: "✕",
3986
+ title: "Force-kill (SIGKILL)",
3987
+ desc: "terminates the process immediately",
3988
+ danger: true,
3989
+ run: (e) => stopAgent(true, e),
3990
+ },
3991
+ ];
3992
+ // Build the command rows for a "/…"-prefixed query, targeting the anchor agent.
3993
+ // Empty when nothing is open (a command with no agent to act on is a no-op) so
3994
+ // the omnibox falls through to its normal search/spawn behaviour.
3995
+ function omniCommandRows(q) {
3996
+ const target = omniAnchor();
3997
+ if (!target) return [];
3998
+ // Match on the FIRST token so a command can carry args after it
3999
+ // (e.g. "/fork fix the auth bug" → cmd "fork", topic "fix the auth bug").
4000
+ const parts = q.slice(1).trim().split(/\s+/).filter(Boolean);
4001
+ const cmdTerm = (parts[0] || "").toLowerCase(); // command token after "/"
4002
+ const rows = OMNI_COMMANDS.filter((c) => c.cmd.slice(1).startsWith(cmdTerm)).map((c) => ({
4003
+ kind: "cmd",
4004
+ cmd: c,
4005
+ target,
4006
+ }));
4007
+ // "/fork [topic]" — a slash alias for the fork spawn row so fork is reachable
4008
+ // straight from the command palette (⌘K → "/fork"), not only via the row that
4009
+ // auto-appends to a search. It's a spawn (kind:"fork"), not a kind:"cmd" that
4010
+ // acts on an existing agent, so it's built here rather than in OMNI_COMMANDS —
4011
+ // omniActivate's fork branch (branch prompt → /api/spawn {fork}) handles it.
4012
+ // Needs a real fork source (the open agent with a live git branch); when the
4013
+ // anchor has none, forking is meaningless so the row is omitted.
4014
+ if ("fork".startsWith(cmdTerm)) {
4015
+ const src = omniForkSource();
4016
+ if (src)
4017
+ rows.push({
4018
+ kind: "fork",
4019
+ branch: forkSlug(parts.slice(1).join(" "), src.git.branch),
4020
+ srcBranch: src.git.branch,
4021
+ fromCwd: src.cwd,
4022
+ cli: src.cli,
4023
+ room: src._room,
4024
+ });
4025
+ }
4026
+ return rows;
4027
+ }
4028
+
3892
4029
  // Which agent the highlighted candidate should preview in the background: an
3893
4030
  // agent row → that agent; the spawn row / an empty list → the launch-time
3894
4031
  // agent (so the backdrop shows the spawn target's cwd).
@@ -3975,6 +4112,16 @@
3975
4112
  <div class="omni-sub">⏎ to choose a working dir &nbsp;·&nbsp; prompt: ${esc(q || "(empty)")}</div>
3976
4113
  </div></div>`;
3977
4114
  }
4115
+ if (r.kind === "cmd") {
4116
+ const t = r.target;
4117
+ const dz = r.cmd.danger ? " danger" : "";
4118
+ return `<div class="omni-row omni-spawn${dz}${sc}" data-i="${i}">
4119
+ <span class="dot ${esc(t.status)}"></span>
4120
+ <div class="omni-main">
4121
+ <div class="omni-title">${r.cmd.icon} ${esc(r.cmd.title)} <span style="opacity:.55">${esc(r.cmd.cmd)}</span></div>
4122
+ <div class="omni-sub">pid ${esc(String(t.pid))} ${esc(cliLabel(t) || ident(t) || "")} &nbsp;·&nbsp; ${esc(r.cmd.desc)}</div>
4123
+ </div></div>`;
4124
+ }
3978
4125
  const e = r.entry;
3979
4126
  const snip = r.snippet ? `<div class="omni-snip">…${r.snippet}…</div>` : "";
3980
4127
  return `<div class="omni-row${sc}" data-i="${i}">
@@ -3993,6 +4140,18 @@
3993
4140
 
3994
4141
  function runOmni() {
3995
4142
  const q = $("omni-input").value.trim();
4143
+ // A "/…" query is a slash command against the open agent, not a search —
4144
+ // show the matching command rows and skip the agent/spawn/tail machinery.
4145
+ if (q.startsWith("/")) {
4146
+ const cmds = omniCommandRows(q);
4147
+ if (cmds.length) {
4148
+ if (omniTailTimer) clearTimeout(omniTailTimer);
4149
+ omniRows = cmds;
4150
+ omniIdx = 0;
4151
+ renderOmni();
4152
+ return;
4153
+ }
4154
+ }
3996
4155
  const toks = q.split(/\s+/).filter(Boolean);
3997
4156
  // Tier 1 — instant title/identity match, ranked title-first then by recency.
3998
4157
  const hits = entries
@@ -4085,6 +4244,13 @@
4085
4244
  const q = $("omni-input").value.trim();
4086
4245
  const row = omniRows[omniIdx];
4087
4246
  const anchor = omniAnchor();
4247
+ // A slash command runs its ⋯-menu handler against the pinned target. Handled
4248
+ // before the spawn fallthrough so ⌘⏎ on a "/kill" row can't spawn instead.
4249
+ if (row && row.kind === "cmd") {
4250
+ closeOmni(false);
4251
+ await row.cmd.run(row.target);
4252
+ return;
4253
+ }
4088
4254
  // ⌘⏎ always = spawn-here (the fast default), regardless of the selected row.
4089
4255
  if (forceSpawn || (row && row.kind === "spawn")) {
4090
4256
  closeOmni(false); // committing — keep the bg as-is, spawnAndSelect takes over
@@ -214,7 +214,7 @@
214
214
  spawn agents from anywhere.
215
215
  </p>
216
216
  <div class="cta">
217
- <a class="btn" href="/w/">Open the console →</a>
217
+ <a class="btn" href="/w/" autofocus>Open the console →</a>
218
218
  <a class="btn ghost" href="https://github.com/snomiao/agent-yes">Star on GitHub</a>
219
219
  </div>
220
220
  <div class="install">
@@ -273,5 +273,15 @@ powershell -c "irm https://agent-yes.com/setup.ps1 | iex"</code></pre>
273
273
  </div>
274
274
  </footer>
275
275
  </div>
276
+ <script>
277
+ // Focus the "Open the console" CTA on load so keyboard/Enter users land
278
+ // straight on it. `autofocus` on <a> isn't honored everywhere, so back it
279
+ // up here — but skip on touch devices where focusing scroll-jumps the page.
280
+ (function () {
281
+ if (matchMedia("(hover: none)").matches) return;
282
+ var cta = document.querySelector(".hero .cta a.btn");
283
+ if (cta) cta.focus({ preventScroll: true });
284
+ })();
285
+ </script>
276
286
  </body>
277
287
  </html>