@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.
- package/CHANGELOG.md +24 -0
- package/README.md +79 -219
- package/docs/agent-notes/README.md +12 -0
- package/docs/agent-notes/adapters.md +1 -1
- package/docs/agent-notes/bus.md +3 -1
- package/docs/agent-notes/daemon.md +13 -0
- package/docs/agent-notes/local-worker.md +2 -2
- package/docs/agent-notes/tasks.md +9 -0
- package/docs/agent-notes/tests.md +1 -1
- package/docs/memory.md +70 -152
- package/docs/operations.md +237 -14
- package/docs/plans/2026-10-16-amem-failure-registry.md +101 -0
- package/docs/plans/2026-10-16-amem-p0-baseline.md +110 -0
- package/docs/plans/2026-10-16-amem-p2-read-tools.md +28 -0
- package/docs/plans/amem-cases.json +482 -0
- package/docs/quickstart.md +9 -2
- package/docs/security.md +39 -2
- package/docs/smoke.md +65 -0
- package/docs/specs/2026-09-19-agent-hub-design.md +113 -5
- package/docs/verification/2026-10-16-0.12.29.md +25 -0
- package/docs/verification/2026-10-16-273-live-verification.md +150 -0
- package/docs/verification/2026-10-17-0.12.30.md +70 -0
- package/docs/verified.json +13 -13
- package/package.json +5 -2
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +64 -44
- package/src/cli/console-state.ts +92 -20
- package/src/cli/console.ts +25 -6
- package/src/cli/help.ts +4 -2
- package/src/cli/identity-audit.ts +2 -2
- package/src/cli/identity.ts +15 -1
- package/src/cli/main.js +2 -2
- package/src/cli/main.ts +78 -39
- package/src/cli/memory.ts +87 -187
- package/src/cli/output.ts +6 -0
- package/src/cli/recovery-package.ts +59 -10
- package/src/cli/upgrade-runtime.ts +60 -18
- package/src/cli/upgrade.ts +8 -0
- package/src/hub/board.ts +2 -1
- package/src/hub/bus.ts +33 -12
- package/src/hub/daemon.ts +141 -31
- package/src/hub/envelope.ts +30 -0
- package/src/hub/events.ts +1 -1
- package/src/hub/git.ts +2 -38
- package/src/hub/hub-tools.ts +5 -1
- package/src/hub/lifecycle.ts +2 -2
- package/src/hub/manager.ts +15 -11
- package/src/hub/memory-candidates.ts +103 -0
- package/src/hub/memory-fail.ts +25 -0
- package/src/hub/memory-observations.ts +47 -0
- package/src/hub/memory-plan-guard.ts +33 -0
- package/src/hub/memory-policy.ts +42 -0
- package/src/hub/memory-profile-check.ts +170 -0
- package/src/hub/memory-profile.ts +192 -0
- package/src/hub/memory-staleness.ts +55 -0
- package/src/hub/path-policy.ts +8 -0
- package/src/hub/project.ts +4 -37
- package/src/hub/read-tools.ts +127 -0
- package/src/hub/tasks.ts +201 -41
- package/src/hub/ui.ts +10 -7
- package/src/local/deny.ts +4 -17
- package/src/memory/brief.ts +8 -5
- package/src/memory/capture.ts +2 -1
- package/src/memory/recall.ts +50 -110
- package/src/pi/process-signature.ts +2 -42
- package/src/memory/import.ts +0 -90
- package/src/memory/store.ts +0 -805
- 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.
|
|
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.
|
|
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.
|
|
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
|
|
6925
|
+
import { join as join3 } from "path";
|
|
6926
6926
|
|
|
6927
6927
|
// src/hub/project.ts
|
|
6928
|
-
import { existsSync, lstatSync, readFileSync
|
|
6929
|
-
import {
|
|
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/
|
|
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 =
|
|
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 ?
|
|
6970
|
+
return root ? canonicalPath(root) : undefined;
|
|
6958
6971
|
}
|
|
6959
6972
|
function hasConfig(dir) {
|
|
6960
|
-
return existsSync(
|
|
6973
|
+
return existsSync(join2(dir, ".agenthub", "config.json"));
|
|
6961
6974
|
}
|
|
6962
6975
|
function projectRoot(cwd) {
|
|
6963
|
-
const input = isAbsolute(cwd) ? cwd :
|
|
6976
|
+
const input = isAbsolute(cwd) ? cwd : resolve2(cwd);
|
|
6964
6977
|
let dir;
|
|
6965
6978
|
try {
|
|
6966
|
-
dir =
|
|
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 =
|
|
6987
|
+
for (let current = dir;; current = dirname2(current)) {
|
|
6975
6988
|
if (hasConfig(current))
|
|
6976
|
-
return
|
|
6977
|
-
if (current === stop || 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 (
|
|
6985
|
-
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 [
|
|
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
|
|
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:
|
|
7016
|
+
return { root, stateDir: canonicalPath(join2(root, ".agenthub", "state")) };
|
|
7004
7017
|
const declared = env.AGENTHUB_PROJECT_DIR?.trim();
|
|
7005
|
-
const stateDir =
|
|
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 &&
|
|
7022
|
+
if (!recorded && declared && canonicalPath(declared) === root)
|
|
7010
7023
|
return { root, stateDir };
|
|
7011
|
-
return { root, stateDir:
|
|
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(
|
|
7023
|
-
const token = readFileSync2(
|
|
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
|
|
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 =
|
|
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(
|
|
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
|
|
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.
|
|
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.
|
|
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("
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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;
|
package/src/cli/console-state.ts
CHANGED
|
@@ -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
|
-
|
|
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]
|
|
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:
|
|
389
|
-
const selection = (s: ConsoleState) =>
|
|
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 =
|
|
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
|
|
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 === "
|
|
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 =
|
|
657
|
-
const
|
|
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`;
|
package/src/cli/console.ts
CHANGED
|
@@ -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,
|
|
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)
|
|
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 {
|
|
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(
|
|
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>", "
|
|
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"],
|