@staix/agent-hub 0.12.28 → 0.12.30

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 (68) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/README.md +79 -219
  3. package/docs/agent-notes/README.md +12 -0
  4. package/docs/agent-notes/adapters.md +1 -1
  5. package/docs/agent-notes/bus.md +3 -1
  6. package/docs/agent-notes/daemon.md +13 -0
  7. package/docs/agent-notes/local-worker.md +2 -2
  8. package/docs/agent-notes/tasks.md +9 -0
  9. package/docs/agent-notes/tests.md +1 -1
  10. package/docs/memory.md +70 -152
  11. package/docs/operations.md +237 -14
  12. package/docs/plans/2026-10-16-amem-failure-registry.md +101 -0
  13. package/docs/plans/2026-10-16-amem-p0-baseline.md +110 -0
  14. package/docs/plans/2026-10-16-amem-p2-read-tools.md +28 -0
  15. package/docs/plans/amem-cases.json +482 -0
  16. package/docs/quickstart.md +9 -2
  17. package/docs/security.md +39 -2
  18. package/docs/smoke.md +65 -0
  19. package/docs/specs/2026-09-19-agent-hub-design.md +113 -5
  20. package/docs/verification/2026-10-16-0.12.29.md +25 -0
  21. package/docs/verification/2026-10-16-273-live-verification.md +150 -0
  22. package/docs/verification/2026-10-17-0.12.30.md +70 -0
  23. package/docs/verified.json +13 -13
  24. package/package.json +5 -2
  25. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  26. package/plugins/agent-hub/server.js +64 -44
  27. package/src/cli/console-state.ts +92 -20
  28. package/src/cli/console.ts +25 -6
  29. package/src/cli/help.ts +4 -2
  30. package/src/cli/identity-audit.ts +2 -2
  31. package/src/cli/identity.ts +15 -1
  32. package/src/cli/main.js +2 -2
  33. package/src/cli/main.ts +78 -39
  34. package/src/cli/memory.ts +87 -187
  35. package/src/cli/output.ts +6 -0
  36. package/src/cli/recovery-package.ts +59 -10
  37. package/src/cli/upgrade-runtime.ts +60 -18
  38. package/src/cli/upgrade.ts +8 -0
  39. package/src/hub/board.ts +2 -1
  40. package/src/hub/bus.ts +33 -12
  41. package/src/hub/daemon.ts +141 -31
  42. package/src/hub/envelope.ts +30 -0
  43. package/src/hub/events.ts +1 -1
  44. package/src/hub/git.ts +2 -38
  45. package/src/hub/hub-tools.ts +5 -1
  46. package/src/hub/lifecycle.ts +2 -2
  47. package/src/hub/manager.ts +15 -11
  48. package/src/hub/memory-candidates.ts +103 -0
  49. package/src/hub/memory-fail.ts +25 -0
  50. package/src/hub/memory-observations.ts +47 -0
  51. package/src/hub/memory-plan-guard.ts +33 -0
  52. package/src/hub/memory-policy.ts +42 -0
  53. package/src/hub/memory-profile-check.ts +170 -0
  54. package/src/hub/memory-profile.ts +192 -0
  55. package/src/hub/memory-staleness.ts +55 -0
  56. package/src/hub/path-policy.ts +8 -0
  57. package/src/hub/project.ts +4 -37
  58. package/src/hub/read-tools.ts +127 -0
  59. package/src/hub/tasks.ts +201 -41
  60. package/src/hub/ui.ts +10 -7
  61. package/src/local/deny.ts +4 -17
  62. package/src/memory/brief.ts +8 -5
  63. package/src/memory/capture.ts +2 -1
  64. package/src/memory/recall.ts +50 -110
  65. package/src/pi/process-signature.ts +2 -42
  66. package/src/memory/import.ts +0 -90
  67. package/src/memory/store.ts +0 -805
  68. package/src/memory/sync.ts +0 -145
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@staix/agent-hub",
3
- "version": "0.12.28",
3
+ "version": "0.12.30",
4
4
  "description": "Native multi-agent hub: Claude Code, Codex, Kimi Code, Pi and local inference as peers in one project",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -14,7 +14,10 @@
14
14
  "test": "bun test"
15
15
  },
16
16
  "engines": {
17
- "bun": ">=1.3.0"
17
+ "bun": ">=1.4.2"
18
+ },
19
+ "dependencies": {
20
+ "@staix/agent-mem": "0.3.0"
18
21
  },
19
22
  "devDependencies": {
20
23
  "@modelcontextprotocol/sdk": "^1.27.1",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-hub",
3
- "version": "0.12.28",
3
+ "version": "0.12.30",
4
4
  "description": "Channel between Claude Code and the agent-hub daemon: peer messages from Codex, Kimi and the local worker arrive as channel events; hub_send replies.",
5
5
  "author": {
6
6
  "name": "Young Joon Lee",
@@ -6922,24 +6922,16 @@ var require_dist = __commonJS(function(exports, module) {
6922
6922
 
6923
6923
  // src/hub/control-client.ts
6924
6924
  import { readFileSync as readFileSync2 } from "fs";
6925
- import { join as join2 } from "path";
6925
+ import { join as join3 } from "path";
6926
6926
 
6927
6927
  // src/hub/project.ts
6928
- import { existsSync, lstatSync, readFileSync, realpathSync } from "fs";
6929
- import { basename, dirname, isAbsolute, join, resolve } from "path";
6930
-
6931
- // src/hub/git.ts
6932
- import { spawnSync } from "child_process";
6933
- var SAFE_CONFIG = ["core.fsmonitor=false", "core.hooksPath=/dev/null", "diff.external=", "core.pager=cat", "credential.helper=", "core.askpass="];
6934
- var CREDENTIAL_KEYS = new Set(["credential.helper=", "core.askpass="]);
6935
- var hubGitArgv = (args, opts = {}) => ["git", "--no-pager", ...SAFE_CONFIG.filter((c) => !opts.credentials || !CREDENTIAL_KEYS.has(c)).flatMap((c) => ["-c", c]), ...args];
6936
- var hubGitEnv = (env = process.env) => ({ ...env, GIT_TERMINAL_PROMPT: "0" });
6937
- function hubGitSync(args, options) {
6938
- const argv = hubGitArgv(args);
6939
- return spawnSync(argv[0], argv.slice(1), { ...options, env: hubGitEnv(options.env ?? process.env) });
6940
- }
6928
+ import { existsSync, lstatSync, readFileSync } from "fs";
6929
+ import { dirname as dirname2, isAbsolute, join as join2, resolve as resolve2 } from "path";
6941
6930
 
6942
- // src/hub/project.ts
6931
+ // node_modules/@staix/agent-mem/src/memory/platform.ts
6932
+ import { realpathSync } from "fs";
6933
+ import { basename, dirname, join, resolve, sep } from "path";
6934
+ import { spawnSync, execFileSync } from "child_process";
6943
6935
  var canonical = (path) => {
6944
6936
  const absolute = resolve(path);
6945
6937
  try {
@@ -6949,21 +6941,42 @@ var canonical = (path) => {
6949
6941
  return parent === absolute ? absolute : join(canonical(parent), basename(absolute));
6950
6942
  }
6951
6943
  };
6944
+ function canonicalPath(path) {
6945
+ return canonical(path);
6946
+ }
6947
+ var SAFE_CONFIG = ["core.fsmonitor=false", "core.hooksPath=/dev/null", "diff.external=", "core.pager=cat", "credential.helper=", "core.askpass="];
6948
+ var CREDENTIAL_KEYS = new Set(["credential.helper=", "core.askpass="]);
6949
+ var memoryGitArgv = (args, opts = {}) => ["git", "--no-pager", ...SAFE_CONFIG.filter((c) => !opts.credentials || !CREDENTIAL_KEYS.has(c)).flatMap((c) => ["-c", c]), ...args];
6950
+ function memoryGitEnv(env = process.env, options = {}) {
6951
+ const result = { ...env, GIT_TERMINAL_PROMPT: "0" };
6952
+ if (options.repositoryIsolation) {
6953
+ for (const key of Object.keys(result))
6954
+ if (["GIT_DIR", "GIT_COMMON_DIR", "GIT_WORK_TREE", "GIT_INDEX_FILE", "GIT_OBJECT_DIRECTORY", "GIT_ALTERNATE_OBJECT_DIRECTORIES", "GIT_NAMESPACE", "GIT_PREFIX", "GIT_SHALLOW_FILE", "GIT_GRAFT_FILE", "GIT_REPLACE_REF_BASE", "GIT_CONFIG_COUNT", "GIT_CONFIG_PARAMETERS"].includes(key) || /^GIT_CONFIG_(KEY|VALUE)_/.test(key))
6955
+ delete result[key];
6956
+ result.GIT_NO_REPLACE_OBJECTS = "1";
6957
+ }
6958
+ return result;
6959
+ }
6960
+ function memoryGitSync(args, options) {
6961
+ const argv = memoryGitArgv(args);
6962
+ return spawnSync(argv[0], argv.slice(1), { ...options, env: memoryGitEnv(options.env ?? process.env) });
6963
+ }
6964
+ // src/hub/project.ts
6952
6965
  function gitRoot(dir) {
6953
- const result = hubGitSync(["-C", dir, "rev-parse", "--show-toplevel"], { encoding: "utf8" });
6966
+ const result = memoryGitSync(["-C", dir, "rev-parse", "--show-toplevel"], { encoding: "utf8" });
6954
6967
  if (result.status !== 0)
6955
6968
  return;
6956
6969
  const root = result.stdout.trim();
6957
- return root ? canonical(root) : undefined;
6970
+ return root ? canonicalPath(root) : undefined;
6958
6971
  }
6959
6972
  function hasConfig(dir) {
6960
- return existsSync(join(dir, ".agenthub", "config.json"));
6973
+ return existsSync(join2(dir, ".agenthub", "config.json"));
6961
6974
  }
6962
6975
  function projectRoot(cwd) {
6963
- const input = isAbsolute(cwd) ? cwd : resolve(cwd);
6976
+ const input = isAbsolute(cwd) ? cwd : resolve2(cwd);
6964
6977
  let dir;
6965
6978
  try {
6966
- dir = canonical(input);
6979
+ dir = canonicalPath(input);
6967
6980
  if (!lstatSync(dir).isDirectory())
6968
6981
  throw new Error(`${cwd} is not a directory`);
6969
6982
  } catch (error) {
@@ -6971,27 +6984,27 @@ function projectRoot(cwd) {
6971
6984
  }
6972
6985
  const boundary = gitRoot(dir);
6973
6986
  const stop = boundary ?? parseRoot(dir);
6974
- for (let current = dir;; current = dirname(current)) {
6987
+ for (let current = dir;; current = dirname2(current)) {
6975
6988
  if (hasConfig(current))
6976
- return canonical(current);
6977
- if (current === stop || current === dirname(current))
6989
+ return canonicalPath(current);
6990
+ if (current === stop || current === dirname2(current))
6978
6991
  break;
6979
6992
  }
6980
6993
  return boundary ?? dir;
6981
6994
  }
6982
6995
  function parseRoot(dir) {
6983
6996
  let current = dir;
6984
- while (dirname(current) !== current)
6985
- current = dirname(current);
6997
+ while (dirname2(current) !== current)
6998
+ current = dirname2(current);
6986
6999
  return current;
6987
7000
  }
6988
7001
  function stateMarker(stateDir) {
6989
- for (const file of [join(stateDir, "project.json"), join(stateDir, "status.json")]) {
7002
+ for (const file of [join2(stateDir, "project.json"), join2(stateDir, "status.json")]) {
6990
7003
  try {
6991
7004
  const value = JSON.parse(readFileSync(file, "utf8"));
6992
7005
  const root = value.root ?? value.cwd;
6993
7006
  if (typeof root === "string")
6994
- return canonical(root);
7007
+ return canonicalPath(root);
6995
7008
  } catch {}
6996
7009
  }
6997
7010
  return;
@@ -7000,15 +7013,15 @@ function projectContext(cwd, env = process.env) {
7000
7013
  const root = projectRoot(cwd);
7001
7014
  const override = env.AGENTHUB_STATE_DIR?.trim();
7002
7015
  if (!override)
7003
- return { root, stateDir: canonical(join(root, ".agenthub", "state")) };
7016
+ return { root, stateDir: canonicalPath(join2(root, ".agenthub", "state")) };
7004
7017
  const declared = env.AGENTHUB_PROJECT_DIR?.trim();
7005
- const stateDir = canonical(override);
7018
+ const stateDir = canonicalPath(override);
7006
7019
  const recorded = stateMarker(stateDir);
7007
7020
  if (recorded === root)
7008
7021
  return { root, stateDir };
7009
- if (!recorded && declared && canonical(declared) === root)
7022
+ if (!recorded && declared && canonicalPath(declared) === root)
7010
7023
  return { root, stateDir };
7011
- return { root, stateDir: canonical(join(root, ".agenthub", "state")) };
7024
+ return { root, stateDir: canonicalPath(join2(root, ".agenthub", "state")) };
7012
7025
  }
7013
7026
 
7014
7027
  // src/hub/control-client.ts
@@ -7019,8 +7032,8 @@ var PROTOCOL = 16;
7019
7032
  var RECOVERY_SOURCE_PROTOCOLS = [9, 10, 11, 12, 13, 14, 15, PROTOCOL];
7020
7033
  function readControl(stateDir) {
7021
7034
  try {
7022
- const status = JSON.parse(readFileSync2(join2(stateDir, "status.json"), "utf8"));
7023
- const token = readFileSync2(join2(stateDir, "control-token"), "utf8").trim();
7035
+ const status = JSON.parse(readFileSync2(join3(stateDir, "status.json"), "utf8"));
7036
+ const token = readFileSync2(join3(stateDir, "control-token"), "utf8").trim();
7024
7037
  if (!Number.isInteger(status.controlPort) || status.controlPort < 1 || status.controlPort > 65535 || !token)
7025
7038
  return;
7026
7039
  return {
@@ -7318,7 +7331,7 @@ var OUTCOMES_KEPT_MS = 7 * 24 * 3600000;
7318
7331
  // src/hub/roles.ts
7319
7332
  import { createHash } from "crypto";
7320
7333
  import { constants, closeSync, fstatSync, lstatSync as lstatSync2, openSync, readSync, readdirSync } from "fs";
7321
- import { join as join3 } from "path";
7334
+ import { join as join4 } from "path";
7322
7335
 
7323
7336
  // templates/roles/dev/software-architect.md
7324
7337
  var software_architect_default = `---
@@ -7738,7 +7751,7 @@ function loadRoleCatalog(cwd) {
7738
7751
  if (!cwd)
7739
7752
  return { roles, errors };
7740
7753
  try {
7741
- const config = join3(cwd, ".agenthub"), dir = join3(config, "roles");
7754
+ const config = join4(cwd, ".agenthub"), dir = join4(config, "roles");
7742
7755
  for (const path of [config, dir]) {
7743
7756
  const stat = lstatSync2(path);
7744
7757
  if (!stat.isDirectory() || stat.isSymbolicLink())
@@ -7750,7 +7763,7 @@ function loadRoleCatalog(cwd) {
7750
7763
  for (const file of files) {
7751
7764
  const id = file.slice(0, -3);
7752
7765
  try {
7753
- roles.set(id, parseRole(projectRole(join3(dir, file)), id, "project"));
7766
+ roles.set(id, parseRole(projectRole(join4(dir, file)), id, "project"));
7754
7767
  } catch {
7755
7768
  errors.push({ id: ID.test(id) ? id : "invalid-file-id", reason: "invalid or unsafe project role" });
7756
7769
  }
@@ -16153,11 +16166,11 @@ class StdioServerTransport {
16153
16166
 
16154
16167
  // src/adapters/claude-channel.ts
16155
16168
  import { readFileSync as readFileSync3 } from "fs";
16156
- import { join as join4 } from "path";
16169
+ import { join as join5 } from "path";
16157
16170
  // package.json
16158
16171
  var package_default = {
16159
16172
  name: "@staix/agent-hub",
16160
- version: "0.12.28",
16173
+ version: "0.12.30",
16161
16174
  description: "Native multi-agent hub: Claude Code, Codex, Kimi Code, Pi and local inference as peers in one project",
16162
16175
  license: "MIT",
16163
16176
  type: "module",
@@ -16171,7 +16184,10 @@ var package_default = {
16171
16184
  test: "bun test"
16172
16185
  },
16173
16186
  engines: {
16174
- bun: ">=1.3.0"
16187
+ bun: ">=1.4.2"
16188
+ },
16189
+ dependencies: {
16190
+ "@staix/agent-mem": "0.3.0"
16175
16191
  },
16176
16192
  devDependencies: {
16177
16193
  "@modelcontextprotocol/sdk": "^1.27.1",
@@ -16265,7 +16281,11 @@ var TASK_TOOLS = [
16265
16281
  tool("hub_task_list", "The task board. PII tasks show as [pii].", { state: { type: "string", enum: ["proposed", "in_progress", "in_review", "approved", "changes_requested"] }, ready: { type: "boolean", description: "only proposed tasks with nothing left to wait for" } }),
16266
16282
  tool("hub_review", "Give your verdict on a task you were asked to review: map the changed signatures and call sites to the task's plan or detail, read the check result (hub_task_show has it, with the done summary), and list what is unmet. Two changes_requested in a row move the task to another peer.", { id, head: { type: "string", description: "full SHA actually reviewed; enforce binds approval to the current PR head" }, verdict: { type: "string", enum: ["approved", "changes_requested"] }, note: str, unmet: { type: "array", items: str, description: "each requirement of the plan or detail that the change does not meet" } }, ["id", "verdict"]),
16267
16283
  tool("hub_checkpoint", "Answer a checkpoint request from the hub (quota or context window pressure): what you were doing, what is half done, what whoever continues must know. Write the same to .agenthub/checkpoint.md first if you can.", { summary: str, request_id: { type: "string", description: "The request id supplied by a context checkpoint request; required for context checkpoints" } }, ["summary"]),
16268
- tool("hub_remember", "Save a decision, finding, contract or fail to the memory all agents share (the hub's memory and claude-mem); the other agents also get it with their next message. A fail is an approach you tried that does not work, and why: the most useful note, it stops the others spending their quota on it. Do not retry what a fail note rules out without new evidence. Conclusions worth recalling, not chatter. A note on an open task is short-term until the task is approved, when its decisions, contracts and fails become long-term; horizon long keeps it now, scope shared lets the person's other projects find it.", { text: str, title: str, kind: { type: "string", enum: [...NOTE_KINDS] }, task: id, horizon: { type: "string", enum: ["short", "long"] }, scope: { type: "string", enum: ["project", "shared"] } }, ["text"])
16284
+ tool("hub_memory_search", "Search daemon-scoped visible memory, fails first. Query <=200 UTF-8 bytes; default 8, max 20 lines; whole output <=4 KB. Incomplete excerpts are marked. No project argument.", { query: { type: "string", maxLength: 200, description: "At most 200 UTF-8 bytes, enforced by the daemon" }, limit: { type: "integer", minimum: 1, maximum: 20 } }, ["query"]),
16285
+ tool("hub_memory_show", "Read up to 8 visible memory ids or unique prefixes (6..64 UTF-8 bytes each). Original ids redirect; unavailable ids share one refusal. Whole output <=8 KB.", { ids: { type: "array", minItems: 1, maxItems: 8, items: { type: "string", minLength: 6, maxLength: 64, description: "6 to 64 UTF-8 bytes" } } }, ["ids"]),
16286
+ tool("hub_memory_graph", "Read screened memory neighbourhoods, fails first. Up to 8 starts (<=200 UTF-8 bytes each), 1 or 2 hops; default 60/max 120 nodes, max 240 edges, whole output <=6 KB. No project argument.", { starts: { type: "array", minItems: 1, maxItems: 8, items: { type: "string", minLength: 1, maxLength: 200, description: "At most 200 UTF-8 bytes" } }, hops: { type: "integer", enum: [1, 2] }, limit: { type: "integer", minimum: 1, maximum: 120 } }, ["starts"]),
16287
+ tool("hub_memory_correct", "Propose a correction to a visible memory entry. This creates a machine-local candidate only; a person accepts or rejects it with amem review. It never edits a note. id is an exact id or unique visible prefix; text and reason must each fit 4000 UTF-8 bytes.", { id: str, text: str, reason: str }, ["id", "text", "reason"]),
16288
+ tool("hub_remember", "Save a decision, finding, contract or fail to the memory all agents share (the hub's memory and claude-mem); the other agents also get it with their next message. A fail is an approach you tried that does not work, and why: the most useful note, it stops the others spending their quota on it. Do not retry what a fail note rules out without new evidence. Conclusions worth recalling, not chatter. For kind fail, optional approach, symptom, cause, evidence and retry_when strings compose fixed labeled text; text may be omitted when structured fields are supplied. The complete text must fit 4000 UTF-8 bytes and is refused, never truncated, when oversized. A note on an open task is short-term until the task is approved, when its decisions, contracts and fails become long-term; horizon long keeps it now, scope shared lets the person's other projects find it.", { text: str, approach: str, symptom: str, cause: str, evidence: str, retry_when: str, title: str, kind: { type: "string", enum: [...NOTE_KINDS] }, task: id, horizon: { type: "string", enum: ["short", "long"] }, scope: { type: "string", enum: ["project", "shared"] } })
16269
16289
  ];
16270
16290
  var TASK_TOOL_NAMES = new Set(TASK_TOOLS.map((t) => t.name));
16271
16291
  var CONDUCTOR_TOOLS = [
@@ -16328,7 +16348,7 @@ var channel = !toolsMode && process.env.AGENTHUB_CHANNEL === "1";
16328
16348
  function roles() {
16329
16349
  const read = (name) => {
16330
16350
  try {
16331
- return JSON.parse(readFileSync3(join4(projectRoot2, ".agenthub", name), "utf8")).roles ?? {};
16351
+ return JSON.parse(readFileSync3(join5(projectRoot2, ".agenthub", name), "utf8")).roles ?? {};
16332
16352
  } catch {
16333
16353
  return {};
16334
16354
  }
@@ -16341,7 +16361,7 @@ function sessionProfile() {
16341
16361
  for (const name of ["config.json", "config.local.json"]) {
16342
16362
  let data;
16343
16363
  try {
16344
- data = JSON.parse(readFileSync3(join4(projectRoot2, ".agenthub", name), "utf8"));
16364
+ data = JSON.parse(readFileSync3(join5(projectRoot2, ".agenthub", name), "utf8"));
16345
16365
  } catch (error) {
16346
16366
  if (error.code === "ENOENT")
16347
16367
  continue;
@@ -16368,7 +16388,7 @@ function sessionRoles() {
16368
16388
  const read = (name) => {
16369
16389
  let raw;
16370
16390
  try {
16371
- raw = JSON.parse(readFileSync3(join4(projectRoot2, ".agenthub", name), "utf8")).catalog_roles;
16391
+ raw = JSON.parse(readFileSync3(join5(projectRoot2, ".agenthub", name), "utf8")).catalog_roles;
16372
16392
  } catch {
16373
16393
  return {};
16374
16394
  }
@@ -16397,7 +16417,7 @@ var HELD = `another session is attached to the hub as "${peerId}"; this one is s
16397
16417
  var INBOX_CAP = 200;
16398
16418
  function peerHeld() {
16399
16419
  try {
16400
- const status = JSON.parse(readFileSync3(join4(stateDir, "status.json"), "utf8"));
16420
+ const status = JSON.parse(readFileSync3(join5(stateDir, "status.json"), "utf8"));
16401
16421
  const peer = status.peers?.[peerId];
16402
16422
  if (channel && peer?.toolsOnly && !peer.claiming)
16403
16423
  return false;
@@ -29,7 +29,9 @@ export interface ConsoleState {
29
29
  approvals: Approval[]; events: ConsoleEvent[]; peers: Record<string, any>; budget: Record<string, any>;
30
30
  tasks: any[]; tasksKnown?: boolean; taskCounts?: Record<string, number>; queue: any[]; detail?: Detail; detailOffset: number; help: boolean; notice: string; noticeAt?: number; noticeTone?: Tone;
31
31
  peerFilter?: string; kindFilter?: string; project?: string;
32
- confirm?: { type: "permission"; id: string; option: string; peer: string } | { type: "command"; args: string[] } | { type: "permission_default"; peer: string; source: string };
32
+ taskSelection?: number; queueSelection?: string;
33
+ actionLock?: { prefix: string[]; id: string; revision: number; state: string; owner?: string; reserved?: string; reviewer?: string; waiting?: boolean };
34
+ confirm?: { type: "permission"; id: string; option: string; peer: string } | { type: "command"; args: string[]; prompt?: string } | { type: "permission_default"; peer: string; source: string };
33
35
  optionChoice?: string;
34
36
  modeChoice?: string;
35
37
  permissionDefaults: PermissionDefault[];
@@ -319,8 +321,8 @@ const KEYS: [string, string[]][] = [
319
321
  ["Panels", ["1-5 panel", "Left/Right panel", "j/k or Up/Down move", "Enter view", "j/k scroll a view"]],
320
322
  ["Approvals", ["a allow (then y)", "d deny", "v view", "[ ] select"]],
321
323
  ["Peers", ["p pause", "r resume", "m permission mode"]],
322
- ["Tasks", ["a assign (then y)", "r review"]],
323
- ["Queue", ["r resolve (reason, then y)"]],
324
+ ["Tasks", ["a assign/reviewer (then y)", "u unassign (then y)", "r review (in review)"]],
325
+ ["Queue", ["r resolve (reason, then y)", "queued=discard, needs_review=retry"]],
324
326
  ["Events", ["f peer filter", "g kind filter"]],
325
327
  ];
326
328
  /** The `?` key table: one group per panel, its keys kept whole and packed into the width after the group column. */
@@ -372,7 +374,7 @@ export function parseConsoleCommand(input: string): string[] {
372
374
  }
373
375
  export function commandNeedsConfirmation(args: string[]): boolean {
374
376
  const positional = args.filter(arg => arg !== "--yes");
375
- return (positional[0] === "permission" && positional[2] === "never-ask") || (args[0] === "task" && args[1] === "assign") || (args[0] === "queue" && args[1] === "resolve") || (args[0] === "permit" && args[2] !== "deny");
377
+ return (positional[0] === "permission" && positional[2] === "never-ask") || (args[0] === "task" && ["assign", "unassign"].includes(args[1] ?? "")) || (args[0] === "queue" && args[1] === "resolve") || (args[0] === "permit" && args[2] !== "deny");
376
378
  }
377
379
  export function panelRows(s: ConsoleState): any[] {
378
380
  if (s.panel === 1) return Object.entries(s.peers).map(([id, peer]) => ({ id, ...peer, budget: s.budget[id] }));
@@ -385,8 +387,38 @@ export function panelRows(s: ConsoleState): any[] {
385
387
  return s.events.filter(e => (!s.peerFilter || e.peer === s.peerFilter) && (!s.kindFilter || e.kind === s.kindFilter));
386
388
  }
387
389
  export const selectedApproval = (s: ConsoleState) => s.approvals.find(a => a.id === s.approvalId);
388
- /** The selected row: the Approvals panel's is the selected request (-1 when none is). */
389
- const selection = (s: ConsoleState) => s.panel === 2 ? s.approvals.findIndex(a => a.id === s.approvalId) : s.selection;
390
+ /** The selected row: approvals and refreshed Tasks/Queue rows are bound by id, never by a replacement row. */
391
+ const selection = (s: ConsoleState) => {
392
+ if (s.panel === 2) return s.approvals.findIndex(a => a.id === s.approvalId);
393
+ if (s.panel === 3 && s.taskSelection !== undefined) return panelRows(s).findIndex(row => row.id === s.taskSelection);
394
+ if (s.panel === 4 && s.queueSelection !== undefined) return panelRows(s).findIndex(row => row.id === s.queueSelection);
395
+ return s.selection;
396
+ };
397
+ export const selectedPanelRow = (s: ConsoleState): any | undefined => panelRows(s)[selection(s)];
398
+ export function captureConsoleSelection(s: ConsoleState, byIndex = false): void {
399
+ const row = byIndex ? panelRows(s)[s.selection] : selectedPanelRow(s);
400
+ if (s.panel === 3 && row) s.taskSelection = Number(row.id);
401
+ if (s.panel === 4 && row) s.queueSelection = String(row.id);
402
+ }
403
+ const commandFlag = (args: string[], name: string) => { const at = args.indexOf(name); return at < 0 ? undefined : args[at + 1]; };
404
+ function validateLockedCommand(lock: NonNullable<ConsoleState["actionLock"]>, args: string[]): void {
405
+ const revisions = args.flatMap((arg, index) => arg === "--revision" ? [args[index + 1]] : []);
406
+ if (args[0] !== lock.prefix[0] || args[1] !== lock.prefix[1] || args[2] !== lock.id || revisions.length !== 1 || revisions[0] !== String(lock.revision)) {
407
+ throw new Error("the selected id and revision cannot be edited; start again from the row");
408
+ }
409
+ }
410
+ function lockedCommandPrompt(lock: NonNullable<ConsoleState["actionLock"]>, args: string[]): string {
411
+ if (lock.prefix[0] === "queue") {
412
+ const action = commandFlag(args, "--action") ?? "resolve";
413
+ return `${action} delivery ${lock.id} at revision ${lock.revision}${action === "completed" ? "; this asserts external effects" : action === "discard" ? "; no task or native turn is cancelled" : ""}`;
414
+ }
415
+ const role = commandFlag(args, "--role") ?? "owner";
416
+ if (lock.prefix[1] === "assign") return `assign ${role} on task #${lock.id} at revision ${lock.revision}${lock.waiting && role === "owner" ? " as a reservation until dependencies approve" : ""}`;
417
+ const affected = role === "owner" ? [lock.owner, lock.reserved].filter(Boolean).join(" and ") : lock.reviewer ?? "none";
418
+ const resulting = role === "owner" ? "proposed" : lock.state;
419
+ const guidance = role === "reviewer" && lock.state !== "in_review" ? "; later completion without a reviewer may approve directly" : lock.state === "in_review" ? `; use ahub review ${lock.id}` : "";
420
+ return `unassign ${role} ${affected} on task #${lock.id} at revision ${lock.revision} -> ${resulting}${guidance}; native work and uncertain deliveries are not stopped or resolved`;
421
+ }
390
422
  export function reduceConsole(state: ConsoleState, key: string, now = Date.now()): { state: ConsoleState; effects: ConsoleEffect[] } {
391
423
  const attentionKeyBlocked = !!(state.confirm || state.optionChoice || state.modeChoice || state.help || state.editing || state.input);
392
424
  // A notice answers the key before this one; one that pruning raises now stays.
@@ -427,24 +459,29 @@ export function reduceConsole(state: ConsoleState, key: string, now = Date.now()
427
459
  }
428
460
  if (s.help && s.mode === "panels" && !["?", "\x1b", "q"].includes(key)) return done(); // the key table is modal
429
461
  if (s.editing || s.input) {
430
- if (key === "\x1b") { s.input = ""; s.editing = false; }
462
+ if (key === "\x1b") { s.input = ""; s.editing = false; s.actionLock = undefined; }
431
463
  else if (key === "\x7f" || key === "\b") s.input = Array.from(s.input).slice(0, -1).join("");
432
464
  else if (key === "\x15") s.input = "";
433
465
  else if (key === "\x1b[A" || key === "\x1b[B") {
434
466
  s.historyIndex = Math.max(0, Math.min(s.history.length, s.historyIndex + (key === "\x1b[A" ? -1 : 1)));
435
467
  s.input = s.history[s.historyIndex] ?? "";
436
468
  } else if (key === "\r" || key === "\n") {
437
- const input = s.input; s.input = ""; s.editing = false;
469
+ const input = s.input, lock = s.actionLock; s.input = ""; s.editing = false; s.actionLock = undefined;
438
470
  if (!input.trim()) return done();
439
471
  try {
440
472
  const parsed = parseConsoleCommand(input);
441
473
  const args = parsed[0] === "permission" ? [...parsed.filter(arg => arg !== "--yes"), ...(parsed.includes("--yes") ? ["--yes"] : [])] : parsed;
442
474
  if (args[0] === "queue" && args[1] === "resolve") {
443
- const reason = args.indexOf("--reason");
475
+ const reason = args.indexOf("--reason"), action = commandFlag(args, "--action");
444
476
  if (reason < 0 || !args[reason + 1]?.trim()) throw new Error("queue resolve requires --reason");
477
+ if (!["completed", "retry", "discard"].includes(action ?? "")) throw new Error("queue resolve requires --action completed|retry|discard");
478
+ }
479
+ if (lock) {
480
+ validateLockedCommand(lock, args);
481
+ if (lock.prefix[0] === "task" && !["owner", "reviewer"].includes(commandFlag(args, "--role") ?? "owner")) throw new Error("role must be owner or reviewer");
445
482
  }
446
483
  s.history = [...s.history, input].slice(-100); s.historyIndex = s.history.length;
447
- if (commandNeedsConfirmation(args)) s.confirm = { type: "command", args };
484
+ if (commandNeedsConfirmation(args)) s.confirm = { type: "command", args, ...(lock ? { prompt: lockedCommandPrompt(lock, args) } : {}) };
448
485
  else effects.push({ type: "command", args });
449
486
  } catch (error) { notify(s, String((error as Error).message), now); }
450
487
  } else if (!key.startsWith("\x1b")) s.input += terminalText(key).replace(/\n/g, "");
@@ -482,7 +519,7 @@ export function reduceConsole(state: ConsoleState, key: string, now = Date.now()
482
519
  const selected = rows.findIndex(row => row.id === item.id);
483
520
  if (selected >= 0) { s.panel = 3; s.selection = selected; navigated = true; }
484
521
  }
485
- if (navigated) { s.detail = undefined; s.requestDetail = undefined; }
522
+ if (navigated) { captureConsoleSelection(s, true); s.detail = undefined; s.requestDetail = undefined; }
486
523
  else {
487
524
  const command = attentionCommand(item);
488
525
  const text = `${item.kind}${item.peer ? ` ${item.peer}` : ""}${item.id ? ` ${item.id}` : ""}: ${command}`;
@@ -493,7 +530,7 @@ export function reduceConsole(state: ConsoleState, key: string, now = Date.now()
493
530
  }
494
531
  if (key === "\t") { s.mode = s.mode === "panels" ? "stream" : "panels"; s.detail = undefined; s.requestDetail = undefined; return done(); }
495
532
  if (key === ":") { s.editing = true; return done(); }
496
- const switchPanel = (panel: number) => { s.panel = panel; s.selection = 0; s.detail = undefined; s.requestDetail = undefined; return done(); };
533
+ const switchPanel = (panel: number) => { s.panel = panel; s.selection = 0; s.taskSelection = undefined; s.queueSelection = undefined; s.detail = undefined; s.requestDetail = undefined; return done(); };
497
534
  if (s.mode === "panels" && /^[1-5]$/.test(key) && !s.optionChoice) return switchPanel(Number(key));
498
535
  // Right and Left are the digit of the next or previous tab, wrapping at both ends (#309); the same guards as the
499
536
  // digits apply (the confirm, mode-choice, key-table and editing branches above returned already).
@@ -508,6 +545,7 @@ export function reduceConsole(state: ConsoleState, key: string, now = Date.now()
508
545
  if (s.detail !== undefined || s.requestDetail) { s.detailOffset = Math.max(0, s.detailOffset + (["j", "\x1b[B"].includes(key) ? 1 : -1)); return done(); }
509
546
  s.selection = Math.max(0, Math.min(panelRows(s).length - 1, selection(s) + (["j", "\x1b[B"].includes(key) ? 1 : -1)));
510
547
  if (s.panel === 2) s.approvalId = s.approvals[s.selection]?.id;
548
+ captureConsoleSelection(s, true);
511
549
  return done();
512
550
  }
513
551
  if (key === "[" || key === "]") {
@@ -537,10 +575,11 @@ export function reduceConsole(state: ConsoleState, key: string, now = Date.now()
537
575
  if (key === "v" && s.mode === "stream") { effects.push({ type: "print", text: permissionText(approval), kind: "permission", tone: "attention" }); return done(); }
538
576
  if (key === "v") { s.requestDetail = approval.id; s.detailOffset = 0; return done(); }
539
577
  }
540
- const row = panelRows(s)[selection(s)];
578
+ const row = selectedPanelRow(s);
541
579
  // With a detail open the keys act on the item it shows (its id), never on what a refresh moved under the selection;
542
580
  // a detail that is only a message names no item.
543
- const item = s.detail === undefined ? row?.id : typeof s.detail === "object" ? s.detail.id : undefined;
581
+ const selected = s.detail === undefined ? row : typeof s.detail === "object" ? s.detail : undefined;
582
+ const item = selected?.id;
544
583
  if (s.mode === "panels" && row && s.detail === undefined && (key === "\r" || key === "\n")) {
545
584
  if (s.panel === 3 || s.panel === 4) effects.push({ type: "show", panel: s.panel, id: String(row.id) });
546
585
  else if (s.panel === 2) { s.requestDetail = row.id; s.detailOffset = 0; } // the row is the selected request
@@ -557,9 +596,28 @@ export function reduceConsole(state: ConsoleState, key: string, now = Date.now()
557
596
  return done();
558
597
  }
559
598
  const prefill = s.panel === 1 && key === "p" ? `pause ${item}` : s.panel === 1 && key === "r" ? `resume ${item}` :
560
- s.panel === 3 && key === "a" ? `task assign ${item} ` : s.panel === 3 && key === "r" ? `review ${item} ` :
561
- s.panel === 4 && key === "r" ? `queue resolve ${item} --action retry --reason ` : undefined;
599
+ s.panel === 3 && key === "r" && selected.state === "in_review" ? `review ${item} ` : undefined;
562
600
  if (prefill) { s.input = prefill; s.editing = true; return done(); }
601
+ if (s.panel === 3 && (key === "a" || key === "u")) {
602
+ if (!["proposed", "in_progress", "in_review", "changes_requested"].includes(selected.state)) return done();
603
+ const revision = selected.flowRevision, role = selected.state === "in_review" || key === "u" && !selected.owner && !selected.reserved && selected.reviewer ? "reviewer" : "owner";
604
+ if (!Number.isSafeInteger(revision) || revision < 0) { notify(s, "task row actions need flowRevision; upgrade the hub", now); return done(); }
605
+ if (key === "u" && (role === "owner" && !selected.owner && !selected.reserved || role === "reviewer" && !selected.reviewer)) { notify(s, `task #${item}: no ${role} to unassign`, now); return done(); }
606
+ const listed = s.tasks.find(task => task.id === item), ready = listed?.ready ?? selected.ready;
607
+ const waiting = selected.state === "proposed" && ready !== true && Array.isArray(selected.deps) && selected.deps.length > 0;
608
+ s.actionLock = { prefix: ["task", key === "a" ? "assign" : "unassign"], id: String(item), revision, state: String(selected.state), ...(selected.owner ? { owner: String(selected.owner) } : {}), ...(selected.reserved ? { reserved: String(selected.reserved) } : {}), ...(selected.reviewer ? { reviewer: String(selected.reviewer) } : {}), waiting };
609
+ s.input = key === "a" ? `task assign ${item} ${role === "reviewer" ? "--role reviewer " : ""}--revision ${revision} ` : `task unassign ${item} --role ${role} --revision ${revision}`;
610
+ s.editing = true; return done();
611
+ }
612
+ if (s.panel === 4 && key === "r") {
613
+ if (!["queued", "needs_review"].includes(selected.state)) return done();
614
+ const revision = selected.revision;
615
+ if (!Number.isSafeInteger(revision) || revision < 0) { notify(s, "queue row actions need a delivery revision; upgrade the hub", now); return done(); }
616
+ const action = selected.state === "queued" ? "discard" : "retry";
617
+ s.actionLock = { prefix: ["queue", "resolve"], id: String(item), revision, state: String(selected.state) };
618
+ s.input = `queue resolve ${item} --action ${action} --revision ${revision} --reason `;
619
+ s.editing = true; return done();
620
+ }
563
621
  }
564
622
  if (s.mode === "panels" && s.panel === 5 && (key === "f" || key === "g")) {
565
623
  const field = key === "f" ? "peer" : "kind"; const current = key === "f" ? s.peerFilter : s.kindFilter;
@@ -646,6 +704,21 @@ export function table(head: string[], rows: Span[][], columns: number, explicitW
646
704
  return { ...cell, text: text + " ".repeat(Math.max(0, widths[i]! - Bun.stringWidth(text)) + (i === head.length - 1 ? 0 : 2)) };
647
705
  }));
648
706
  }
707
+ function taskHints(row: any): string[] {
708
+ if (!["proposed", "in_progress", "in_review", "changes_requested"].includes(row.state)) return [];
709
+ const review = row.state === "in_review";
710
+ const hints = [review ? "a reviewer" : "a assign"];
711
+ if (review ? row.reviewer : row.owner || row.reserved) hints.push("u unassign");
712
+ if (review) hints.push("r review");
713
+ return hints;
714
+ }
715
+ function panelContext(s: ConsoleState, row: any): string[] {
716
+ if (!row) return [];
717
+ if (s.panel === 1) return ["p pause", "r resume", "m permission mode"];
718
+ if (s.panel === 3) return taskHints(row);
719
+ if (s.panel === 4) return [row.state === "queued" ? "r discard" : "r resolve"];
720
+ return [];
721
+ }
649
722
  /** The footer's key hint: only keys that act in this mode, panel and state. */
650
723
  function hint(s: ConsoleState, columns: number): string {
651
724
  const pending = selectedApproval(s) ? ["a allow", "d deny"] : [];
@@ -653,9 +726,8 @@ function hint(s: ConsoleState, columns: number): string {
653
726
  if (s.help) return "? or Esc close q quit";
654
727
  const approvals = s.panel === 2 ? pending : [];
655
728
  if (s.detail !== undefined || s.requestDetail) return [...approvals, "j/k scroll", "Esc back", "? keys", "q quit"].join(" ");
656
- const rows = panelRows(s); const row = rows[selection(s)];
657
- const context = !row ? [] : s.panel === 1 ? ["p pause", "r resume", "m permission mode"] : s.panel === 3 ? ["a assign", "r review"] : s.panel === 4 ? ["r resolve"] : [];
658
- const text = [...approvals, ...context, ...(s.panel === 5 ? ["f peer", "g kind"] : []), ...(rows.length ? ["j/k move"] : []), ...(row ? ["Enter view"] : []), "? keys", ": command", "Tab stream", "q quit"].join(" ");
729
+ const rows = panelRows(s); const row = selectedPanelRow(s);
730
+ const text = [...approvals, ...panelContext(s, row), ...(s.panel === 5 ? ["f peer", "g kind"] : []), ...(rows.length ? ["j/k move"] : []), ...(row ? ["Enter view"] : []), "? keys", ": command", "Tab stream", "q quit"].join(" ");
659
731
  return s.panel === 1 && Bun.stringWidth(text) > columns ? text.replace("m permission mode", "m mode").replace("j/k move", "j/k").replace(": command", ": cmd") : text;
660
732
  }
661
733
  /** The project, then the tabs: the active one in brackets, pending approvals and held deliveries counted. */
@@ -705,7 +777,7 @@ export function renderConsoleLines(s: ConsoleState, columns: number, rows = 24,
705
777
  const permission = selectedApproval(s);
706
778
  let prompt = s.editing ? `: ${s.input}` : hint(s, columns);
707
779
  if (s.confirm?.type === "permission") prompt = `allow ${quoted(s.confirm.option)} for ${s.confirm.peer} (request ${s.confirm.id})? y/N`;
708
- if (s.confirm?.type === "command") prompt = `${s.confirm.args.join(" ")}? y/N`;
780
+ if (s.confirm?.type === "command") prompt = `${s.confirm.prompt ?? s.confirm.args.join(" ")}? y/N`;
709
781
  if (s.modeChoice) prompt = `permission ${s.modeChoice}: 1 ask 2 ask-when-needed 3 never-ask Esc cancel`;
710
782
  const choice = s.optionChoice ? s.approvals.find(a => a.id === s.optionChoice) : undefined;
711
783
  if (choice) prompt = `allow request ${choice.id} (${choice.peer}) with: ${allowOptions(choice).map((o, i) => `${i + 1} ${quoted(o.name)}`).join(" ")} Esc cancel`;
@@ -4,7 +4,7 @@ import { StringDecoder } from "node:string_decoder";
4
4
  import type { ControlClient } from "../hub/control-client.ts";
5
5
  import { contextLine } from "./status-lines.ts";
6
6
  import { renderTailEvent } from "./tail-render.ts";
7
- import { syncPermissionDefaults, permissionDefaultText, approvalFrom, initialConsoleState, keyTable, notify, paint, panelRows, permissionText, plural, pruneApprovals, reduceConsole, renderConsoleLines, resolveColor, stateTone, streamTokens, wrapStreamTokens, terminalText, wrap } from "./console-state.ts";
7
+ import { syncPermissionDefaults, permissionDefaultText, approvalFrom, initialConsoleState, keyTable, notify, paint, permissionText, plural, pruneApprovals, reduceConsole, renderConsoleLines, resolveColor, stateTone, streamTokens, wrapStreamTokens, terminalText, wrap, captureConsoleSelection, selectedPanelRow } from "./console-state.ts";
8
8
  import type { ConsoleEffect, ConsoleEvent, Detail, Tone } from "./console-state.ts";
9
9
  import type { BusEvent } from "../hub/bus.ts";
10
10
  import { consoleNeeds, attentionFacts } from "./attention.ts";
@@ -28,6 +28,8 @@ export interface ConsoleOptions {
28
28
  terminal?: ConsoleTerminal;
29
29
  color?: boolean;
30
30
  noPet?: boolean;
31
+ /** Explicit delegation inherited only by this console's ahub subprocesses. */
32
+ liveTest?: boolean;
31
33
  /** Injected command runner must use argv, closed stdin, and return a cancellation handle. */
32
34
  runCommand?: (args: string[], output: (text: string) => void, done: () => void, columns?: number) => (() => void);
33
35
  pollMs?: number;
@@ -80,7 +82,7 @@ export async function runConsole(options: ConsoleOptions): Promise<void> {
80
82
  let lastAct = 0; let lastBell = -Infinity;
81
83
  let columns = options.columns ?? terminal.columns; let rows = options.rows ?? terminal.rows;
82
84
  let active = true; let inAlternate = false; let plain = !terminal.isTTY || columns < 80 || rows < 24;
83
- let polling = false; let childRunning = false; let cancelChild: (() => void) | undefined;
85
+ let polling = false; let refreshPending = false; let childRunning = false; let cancelChild: (() => void) | undefined;
84
86
  const priorPush = client.onPush; const priorClose = client.onClose; const priorRaw = !!terminal.isRaw;
85
87
  const removers: (() => void)[] = []; const timers: ReturnType<typeof setInterval>[] = [];
86
88
  let resolveDone: () => void = () => {}; const done = new Promise<void>(resolve => { resolveDone = resolve; });
@@ -157,8 +159,20 @@ export async function runConsole(options: ConsoleOptions): Promise<void> {
157
159
  safeWrite(`${reason ? reason + "\n" : ""}Left console; the hub keeps running (ahub kill stops it).\n`);
158
160
  resolveDone();
159
161
  };
162
+ const reconcileDetail = () => {
163
+ if (state.detail === undefined || typeof state.detail !== "object" || (state.panel !== 3 && state.panel !== 4)) return;
164
+ const id = state.detail.id;
165
+ if (id === undefined || id === null) return;
166
+ const key = state.panel === 3 ? "flowRevision" : "revision", rows = state.panel === 3 ? state.tasks : state.queue;
167
+ const fresh = rows.find(row => String(row.id) === String(id));
168
+ if (!fresh || state.detail[key] !== fresh[key]) {
169
+ state.detail = undefined; state.detailOffset = 0;
170
+ notice(`detail ${id} changed or is no longer available`);
171
+ }
172
+ };
160
173
  const refresh = async () => {
161
- if (polling || !active) return; polling = true;
174
+ if (polling || !active) { if (active) refreshPending = true; return; }
175
+ polling = true;
162
176
  try {
163
177
  const panels = state.mode === "panels" && !plain;
164
178
  const replies = await Promise.all([
@@ -167,6 +181,7 @@ export async function runConsole(options: ConsoleOptions): Promise<void> {
167
181
  ]);
168
182
  if (!active) return;
169
183
  const [status, budget, tasks, queue, ready] = replies;
184
+ captureConsoleSelection(state);
170
185
  if (status.status?.tasks && status.ok !== false) state.taskCounts = status.status.tasks;
171
186
  if (status.status?.peers) state.peers = Object.fromEntries(Object.entries(status.status.peers).map(([peer, value]) => [peer, { ...(value as object), ...status.status.roleInfo?.[peer] }]));
172
187
  if (status.status && status.ok !== false) {
@@ -182,13 +197,17 @@ export async function runConsole(options: ConsoleOptions): Promise<void> {
182
197
  state.tasksKnown = true;
183
198
  } }
184
199
  if (queue?.ok && state.mode === "panels" && Array.isArray(queue.deliveries)) state.queue = queue.deliveries;
200
+ reconcileDetail();
185
201
  const error = replies.find(reply => reply.ok === false)?.error; if (error) notice(String(error));
186
202
  draw();
187
203
  } catch (error) { if (active) { notice(String((error as Error).message)); draw(); } }
188
- finally { polling = false; }
204
+ finally {
205
+ polling = false;
206
+ if (refreshPending) { refreshPending = false; void refresh(); }
207
+ }
189
208
  };
190
209
  const run = options.runCommand ?? ((args, output, finished, commandColumns) => {
191
- const child = Bun.spawn([process.execPath, fileURLToPath(new URL("./main.ts", import.meta.url)), "--project", options.cwd, ...args], {
210
+ const child = Bun.spawn([process.execPath, fileURLToPath(new URL("./main.ts", import.meta.url)), "--project", options.cwd, ...(options.liveTest ? ["--live-test"] : []), ...args], {
192
211
  cwd: options.cwd, stdin: "ignore", stdout: "pipe", stderr: "pipe",
193
212
  env: { ...process.env, COLUMNS: String(commandColumns), NO_COLOR: "1" },
194
213
  });
@@ -221,7 +240,7 @@ export async function runConsole(options: ConsoleOptions): Promise<void> {
221
240
  const result = await client.request(action.panel === 3 ? { t: "task", op: "task_show", args: { id: action.id } } : { t: "queue", op: "show", id: action.id }, 3000);
222
241
  // A late reply is for the panel and row it was asked for: once the person has moved on, it would show one item
223
242
  // while the keys act on another, so it is dropped.
224
- if (!active || state.mode !== "panels" || state.panel !== action.panel || state.help || state.detail !== undefined || String(panelRows(state)[state.selection]?.id) !== action.id) return;
243
+ if (!active || state.mode !== "panels" || state.panel !== action.panel || state.help || state.detail !== undefined || String(selectedPanelRow(state)?.id) !== action.id) return;
225
244
  state.detailOffset = 0;
226
245
  state.detail = result.ok === false ? String(result.error) : action.panel === 3 ? labeled(String(result.text)) : result.delivery ?? "delivery not found";
227
246
  draw(); return;
package/src/cli/help.ts CHANGED
@@ -11,6 +11,7 @@ const SECTIONS: [string, [usage: string, description: string][]][] = [
11
11
  ["ahub projects remove <id>", "forget a stopped registration (keeps project files)"],
12
12
  ["ahub setup [--yes]", "install or update the Claude Code channel plugin from this package, then run doctor"],
13
13
  ["ahub init [--profile dev|research|writing|general] [--shadow] [--dry-run --json]", "preview or write .agenthub/config.json and the AGENTS.md marker block (drops a legacy CLAUDE.md block)"],
14
+ ["ahub --live-test [--project <path|id>] <command> [args...]", "user-authorized operational console delegation; ordinary calls retain peer identity; person-authored content refuses"],
14
15
  ["ahub help [command]", "this help, or one command's entries; also --help and -h"],
15
16
  ["ahub version | --version", "print the installed version"],
16
17
  ]],
@@ -64,7 +65,7 @@ const SECTIONS: [string, [usage: string, description: string][]][] = [
64
65
  ["ahub pause|resume <peer>", "hold a peer's deliveries in its queue / release them"],
65
66
  ["ahub queue list [--peer <id>] [--json] [--color=auto|always|never]", "inspect durable deliveries"],
66
67
  ["ahub queue show <delivery-id> [--json] [--color=auto|always|never]", "inspect one delivery and revision"],
67
- ["ahub queue resolve <delivery-id> --action completed|retry|discard --reason <text>", "release a delivery held as needs_review: mark it completed, retry it or discard it"],
68
+ ["ahub queue resolve <delivery-id> --action completed|retry|discard --reason <text> [--revision N]", "resolve a queued or needs_review delivery; --revision binds the displayed row instead of a fresh show"],
68
69
  ]],
69
70
  ["Tasks and review", [
70
71
  ["ahub board import github --label <label> [--milestone <milestone>] [--repo <owner/repo>]", "person only: read bounded open issues into deduplicated Backlog; never writes GitHub"],
@@ -77,7 +78,8 @@ const SECTIONS: [string, [usage: string, description: string][]][] = [
77
78
  ["ahub backlog pull|return <id>", "person only: pull backlog into To Do or return ownerless proposed work; --backlog on task propose stages unoffered work"],
78
79
  ["ahub task show|escalate <id>", "full task with history (PII text included) / hand it to the next peer in escalate_to"],
79
80
  ["ahub task role <id> <role|none>", "person only: change bounded catalog guidance on an open task; routing and authority are unchanged"],
80
- ["ahub task assign <id> <peer> [--role owner|reviewer]", "name the owner (default) or reviewer; a task that still waits reserves its owner"],
81
+ ["ahub task assign <id> <peer> [--role owner|reviewer] [--revision N]", "name the owner (default) or reviewer; a task that still waits reserves its owner; --revision refuses stale console actions"],
82
+ ["ahub task unassign <id> [--role owner|reviewer] [--revision N]", "person only: remove the owner/reservation or reviewer without routing a replacement or stopping native work"],
81
83
  ["ahub task label <id> ok|regressed|reverted|incomplete|wrong|abandoned", "your later verdict on an approved task, kept in the research records (research must be on)"],
82
84
  ["ahub review <id> approved|changes_requested [note...] [--unmet <item>]...", "give a review verdict on a task"],
83
85
  ["ahub route explain <id>", "why a task went where it went, its reserved owner included"],