@staix/agent-hub 0.12.45 → 0.13.0
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 +18 -0
- package/README.md +83 -116
- package/docs/agent-notes/README.md +6 -0
- package/docs/agent-notes/adapters.md +10 -7
- package/docs/agent-notes/budget.md +6 -1
- package/docs/agent-notes/bus.md +6 -4
- package/docs/agent-notes/daemon.md +6 -1
- package/docs/agent-notes/local-worker.md +2 -1
- package/docs/agent-notes/tasks.md +10 -0
- package/docs/agent-notes/tests.md +4 -2
- package/docs/dogfood.md +92 -0
- package/docs/events.md +14 -1
- package/docs/operations.md +250 -19
- package/docs/profiles.md +2 -2
- package/docs/quickstart.md +8 -2
- package/docs/sdlc.md +36 -6
- package/docs/security.md +20 -11
- package/docs/smoke.md +9 -4
- package/docs/specs/2026-09-19-agent-hub-design.md +759 -8
- package/docs/verification/2026-10-19-reviewer-isolation.md +2 -0
- package/docs/verification/2026-10-22-code-facts.md +78 -0
- package/docs/verification/2026-10-22-codex-reviewer-isolation.md +47 -0
- package/docs/verification/2026-10-22-kimi-qwen-compat.md +79 -0
- package/docs/verification/2026-10-23-0.13.0.md +89 -0
- package/docs/verification/dogfood/2026-10-22.md +3109 -0
- package/docs/verified.json +14 -14
- package/package.json +1 -1
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +613 -329
- package/src/adapters/agy/entry.ts +5 -4
- package/src/adapters/claude-channel.ts +20 -9
- package/src/adapters/codex-appserver.ts +18 -2
- package/src/adapters/codex-review-isolation.ts +12 -5
- package/src/adapters/compat.ts +140 -0
- package/src/adapters/definitions/builtin-metadata.ts +2 -1
- package/src/adapters/definitions/native-cli-doctor.ts +12 -5
- package/src/adapters/definitions/native-cli-entry-jcode.ts +9 -6
- package/src/adapters/definitions/native-cli-jcode-mcp.ts +9 -2
- package/src/adapters/definitions/native-cli-preview-common.ts +2 -0
- package/src/adapters/definitions/native-cli-preview-jcode.ts +8 -2
- package/src/adapters/definitions/native-cli-preview-qwen.ts +2 -1
- package/src/adapters/definitions/qwen-native.ts +7 -3
- package/src/adapters/definitions/qwen.ts +2 -1
- package/src/adapters/jcode.ts +72 -31
- package/src/adapters/kimi-review.ts +6 -3
- package/src/adapters/registry-types.ts +3 -1
- package/src/adapters/registry.ts +1 -0
- package/src/cli/agents.ts +40 -0
- package/src/cli/console-review.ts +54 -0
- package/src/cli/console-state.ts +124 -29
- package/src/cli/console-symbols.ts +74 -0
- package/src/cli/console-usage.ts +89 -0
- package/src/cli/console.ts +26 -1
- package/src/cli/help.ts +16 -6
- package/src/cli/identity.ts +29 -6
- package/src/cli/init.ts +109 -11
- package/src/cli/main.ts +122 -11
- package/src/cli/output.ts +44 -1
- package/src/cli/preview.ts +1 -1
- package/src/cli/sdlc-release-guard.ts +82 -0
- package/src/hub/action-class.ts +362 -0
- package/src/hub/action-policy.ts +106 -0
- package/src/hub/board.ts +43 -8
- package/src/hub/budget.ts +71 -2
- package/src/hub/bus.ts +43 -3
- package/src/hub/checks.ts +80 -31
- package/src/hub/code-facts.ts +223 -0
- package/src/hub/config-apply.ts +10 -1
- package/src/hub/config-trust.ts +7 -4
- package/src/hub/daemon.ts +237 -22
- package/src/hub/dogfood-read.ts +199 -0
- package/src/hub/dogfood.ts +163 -0
- package/src/hub/envelope.ts +1 -1
- package/src/hub/events.ts +11 -2
- package/src/hub/hub-tools.ts +24 -2
- package/src/hub/inbox-polling.ts +25 -0
- package/src/hub/instances.ts +73 -1
- package/src/hub/profiles.ts +7 -2
- package/src/hub/release-worktree.ts +56 -0
- package/src/hub/review-brief.ts +36 -0
- package/src/hub/sdlc-queue.ts +31 -9
- package/src/hub/sdlc-release-authorization.ts +173 -0
- package/src/hub/sdlc.ts +49 -11
- package/src/hub/settings.ts +5 -2
- package/src/hub/snapshots.ts +93 -8
- package/src/hub/symbol-normalizer.ts +26 -0
- package/src/hub/task-code-symbols.ts +90 -0
- package/src/hub/task-sweep.ts +3 -2
- package/src/hub/tasks.ts +292 -54
- package/src/hub/test-integrity.ts +162 -0
- package/src/hub/usage-rollup.ts +361 -0
- package/src/local/sandbox.ts +5 -3
- package/src/ui/index.html +1 -0
- package/templates/AGENTS.block.md +1 -1
- package/templates/profiles/dev.json +6 -1
- package/templates/profiles/general.json +6 -1
- package/templates/profiles/research.json +6 -1
- package/templates/profiles/writing.json +6 -1
- package/templates/release-guard.block.md +6 -0
package/src/hub/daemon.ts
CHANGED
|
@@ -1,3 +1,8 @@
|
|
|
1
|
+
import { InboxPolling } from "./inbox-polling.ts";
|
|
2
|
+
import { ActionBudgets, actionPolicyConfig, type ActionPolicyConfig } from "./action-policy.ts";
|
|
3
|
+
import { ReleaseAuthorizations, releaseAuthorizationError } from "./sdlc-release-authorization.ts";
|
|
4
|
+
import { parseReleaseGuardArgv, parseReleasePushUpdates } from "../cli/sdlc-release-guard.ts";
|
|
5
|
+
import type { DocEvidence } from "./sdlc-release.ts";
|
|
1
6
|
import { loadExternalAdapterDefinitions, readAdapterModuleDeclarations, type AdapterModuleDeclaration } from "../adapters/external-modules.ts";
|
|
2
7
|
import { WsPeer, isWsPeer } from "../adapters/channel-peer.ts";
|
|
3
8
|
import { ownerToolEffect, canonicalWriteUnknown } from "./no-writes.ts";
|
|
@@ -19,11 +24,13 @@ import { needs, type NeedsInput } from "../ui/needs.ts";
|
|
|
19
24
|
import { loadPetConfig } from "../pet/config.ts";
|
|
20
25
|
import { projectionConfig, TextProjections, type ProjectionConfig } from "./text-projection.ts";
|
|
21
26
|
import { policyPaths, matchesPolicy } from "./path-policy.ts";
|
|
22
|
-
import {
|
|
27
|
+
import { CodeFacts, type CodeFactResult, type CodeSymbol } from "./code-facts.ts";
|
|
28
|
+
import { buildReviewBrief, withCodeSymbols } from "./review-brief.ts";
|
|
23
29
|
import { profileName, projectProfile, resolveProfile, mergeProfileOverrides, type ProfileName } from "./profiles.ts";
|
|
24
30
|
import { catalogAssignments, loadRoleCatalog, selectedRoles, roleIdentity } from "./roles.ts";
|
|
25
31
|
import { ActivityRing } from "./activity.ts";
|
|
26
|
-
import { JcodeOptionMismatch, isJcodePeer, type JcodePeer } from "../adapters/jcode.ts";
|
|
32
|
+
import { JcodeOptionMismatch, isJcodePeer, jcodeVersionAsync, type JcodePeer } from "../adapters/jcode.ts";
|
|
33
|
+
import { VERSION_REFUSAL, describe, isCompatId } from "../adapters/compat.ts";
|
|
27
34
|
import { jcodeMcpReady } from "../cli/jcode-mcp.ts";
|
|
28
35
|
import { loadSdlc, readSpecEdit, sdlcGh } from "./sdlc.ts";
|
|
29
36
|
import { claudePeer as defaultChannelPeer, claudeStateFile, ownTerminalRow, pathEvidence, transcriptPersisted } from "./claude-state.ts";
|
|
@@ -33,7 +40,7 @@ import { RecoveryDeferredHubError } from "./deferred-hub.ts";
|
|
|
33
40
|
import { databaseBusySite, isDatabaseBusy, RecoveryDatabaseBusy } from "./recovery-error.ts";
|
|
34
41
|
import { ADAPTERS, DEFAULT_REVIEW_POOL, REVIEW_POOL_KINDS, QUOTA_HANDOFF_PLATFORMS, NATIVE_CONFIG_BLOCKS, nativeConfigurationDefaults, mergeNativeConfiguration, retainNativeRouting, DAEMON_PORTS, definitionOf, createProjectAdapterCatalog } from "../adapters/registry.ts";
|
|
35
42
|
import { isSupportedPeer as registrySupportedPeer, definitionOf as builtinDefinitionOf, KNOWN_PEERS as BASE_KNOWN_PEERS, SUPPORTED_PEERS as BASE_SUPPORTED_PEERS, STARTABLE_PEERS as BASE_STARTABLE_PEERS } from "../adapters/registry.ts";
|
|
36
|
-
import { instanceDefinitionOf, effectiveInstanceRoles, instancePeerIds, validateInstances, type InstancesConfig } from "./instances.ts";
|
|
43
|
+
import { checkJcodeInstanceHomes, instanceDefinitionOf, effectiveInstanceRoles, instancePeerIds, validateInstances, type InstancesConfig } from "./instances.ts";
|
|
37
44
|
import { INSTANCE_CONFIG_FILES, readInstanceConfig, readInstanceConfigLayers, readInstanceRoleConfig } from "./instance-config.ts";
|
|
38
45
|
import { instanceBudgetHooks, projectBudgetHeadroom } from "./instance-budget.ts";
|
|
39
46
|
import { resolveInstanceExecutionCwd } from "./instance-execution.ts";
|
|
@@ -76,6 +83,7 @@ import { Capture, skipTools } from "../memory/capture.ts";
|
|
|
76
83
|
import { DEFAULT_OMNIROUTE, OmniRoute, type OmniRouteConfig } from "../omniroute/client.ts";
|
|
77
84
|
import { Briefs } from "../memory/brief.ts";
|
|
78
85
|
import { Board, isClosedTaskState, taskProposalAt, taskHasStarted, CLASSES, type Task, type TaskClass } from "./board.ts";
|
|
86
|
+
import { UsageRollup, USAGE_GROUPS, LIMITS_MAX, type UsageGroup } from "./usage-rollup.ts";
|
|
79
87
|
import { Budget, readNativeQuota, DEFAULT_BUDGET, type BudgetConfig } from "./budget.ts";
|
|
80
88
|
import { HUB } from "./envelope.ts";
|
|
81
89
|
import { recallMemoryLines, trimToTokens } from "../memory/recall.ts";
|
|
@@ -145,6 +153,7 @@ export interface HubConfig {
|
|
|
145
153
|
jcode_cmd: string[];
|
|
146
154
|
jcode_home: string;
|
|
147
155
|
jcode_provider_profile: string;
|
|
156
|
+
action_policy?: ActionPolicyConfig;
|
|
148
157
|
permission_modes: Record<string, PermissionMode>;
|
|
149
158
|
protect_instruction_files: boolean;
|
|
150
159
|
/** Loader-owned provenance of requested defaults, before machine-local filtering. */
|
|
@@ -218,6 +227,7 @@ const nativeDefaults = nativeConfigurationDefaults({ home: homedir(), env: proce
|
|
|
218
227
|
export const DEFAULT_CONFIG: HubConfig = {
|
|
219
228
|
watchdog_ms: DEFAULT_WATCHDOG_MS,
|
|
220
229
|
...nativeDefaults.commands,
|
|
230
|
+
action_policy: actionPolicyConfig(),
|
|
221
231
|
permission_modes: {},
|
|
222
232
|
protect_instruction_files: false,
|
|
223
233
|
...nativeDefaults.proxy,
|
|
@@ -263,7 +273,7 @@ const PEER_ID = /^[a-z][a-z0-9-]{0,31}$/;
|
|
|
263
273
|
/** The shared project config, then the machine's own file, which overrides it block by block (issue #17). */
|
|
264
274
|
const CONFIG_FILES = INSTANCE_CONFIG_FILES;
|
|
265
275
|
export interface ConfigLayer { name: typeof CONFIG_FILES[number]; raw: Record<string, any> }
|
|
266
|
-
const CONFIG_BLOCKS = ["memory", "roster", "roles", "catalog_roles", "pii", "data", "conductor", "budget", "context", "inference", "omniroute", ...NATIVE_CONFIG_BLOCKS, "approvals", "tasks", "task_sweep", "checks", "snapshots", "research", "bench", "limits", "review", "recovery", "capabilities", "mlx", "permission_modes", "peers", "terminal", "pet"];
|
|
276
|
+
const CONFIG_BLOCKS = ["action_policy", "memory", "roster", "roles", "catalog_roles", "pii", "data", "conductor", "budget", "context", "inference", "omniroute", ...NATIVE_CONFIG_BLOCKS, "approvals", "tasks", "task_sweep", "checks", "snapshots", "research", "bench", "limits", "review", "recovery", "capabilities", "mlx", "permission_modes", "peers", "terminal", "pet"];
|
|
267
277
|
|
|
268
278
|
/** Connection-time policy refresh reads role/feed fields only, never launches or machine-local configuration. */
|
|
269
279
|
export function loadConductorPolicy(cwd: string, layers?: readonly ConfigLayer[]): { roles: Record<string, string[]>; conductor: HubConfig["conductor"]; planner?: string; notices?: string[] } {
|
|
@@ -358,6 +368,7 @@ export function loadConfig(cwd: string, layers?: readonly ConfigLayer[], routing
|
|
|
358
368
|
if (file.jcode_home !== undefined && (typeof file.jcode_home !== "string" || !isAbsolute(file.jcode_home))) throw new Error("jcode_home must be an absolute path");
|
|
359
369
|
for (const key of ["jcode_home", "jcode_provider_profile"]) if (file[key] !== undefined && typeof file[key] !== "string") throw new Error(`${key} must be a string`);
|
|
360
370
|
if (file.qwen_cmd !== undefined && (!Array.isArray(file.qwen_cmd) || !file.qwen_cmd.length || file.qwen_cmd.some((word: unknown) => typeof word !== "string" || !word))) throw new Error("qwen_cmd must be a nonempty executable argv");
|
|
371
|
+
actionPolicyConfig(file.action_policy);
|
|
361
372
|
instructionProtection(file.protect_instruction_files);
|
|
362
373
|
permissionDefaults(file.permission_modes, instances, adapterCatalog); // malformed declarations fail even when trust would drop them
|
|
363
374
|
peerStartConfig(file.peers, instances, adapterCatalog);
|
|
@@ -421,6 +432,7 @@ export function loadConfig(cwd: string, layers?: readonly ConfigLayer[], routing
|
|
|
421
432
|
...(file.text_projection !== undefined ? { text_projection: projectionConfig(file.text_projection) } : {}),
|
|
422
433
|
...(file.pii !== undefined ? { pii: { paths: policyPaths(file.pii.paths, "pii.paths") } } : {}),
|
|
423
434
|
...(file.data !== undefined ? { data: { readonly: policyPaths(file.data.readonly, "data.readonly") } } : {}),
|
|
435
|
+
action_policy: actionPolicyConfig(file.action_policy),
|
|
424
436
|
permission_modes: { ...instancePermissions, ...permissionDefaults(file.permission_modes, instances, adapterCatalog) },
|
|
425
437
|
protect_instruction_files: instructionProtection(file.protect_instruction_files),
|
|
426
438
|
permission_default_sources: appliedPermissionSources,
|
|
@@ -486,6 +498,8 @@ export interface DaemonOptions {
|
|
|
486
498
|
checkoutBranch?: typeof mainCheckoutBranch;
|
|
487
499
|
/** Host-only admission transport; never selected by project config or tools. */
|
|
488
500
|
releaseHostAdmission?: (cwd: string) => Promise<ReleaseHostAdmission>;
|
|
501
|
+
/** Host-only evidence seam; never supplied by project config or control messages. */
|
|
502
|
+
releaseDocEvidence?: (root: string) => Promise<DocEvidence | undefined>;
|
|
489
503
|
projectId?: string;
|
|
490
504
|
instanceId?: string;
|
|
491
505
|
cwd: string;
|
|
@@ -532,10 +546,29 @@ interface Client {
|
|
|
532
546
|
type Sock = ServerWebSocket<Client>;
|
|
533
547
|
|
|
534
548
|
|
|
549
|
+
/**
|
|
550
|
+
* #713: why `ahub queue resolve` was refused, from a closed list: the bus and the journal throw their own messages, and only these causes are named, never
|
|
551
|
+
* free text from an error (an id or a body would leak). An error that is none of them keeps the old generic sentence.
|
|
552
|
+
*/
|
|
553
|
+
const RESOLUTION_CAUSES: [RegExp, string][] = [
|
|
554
|
+
[/active delivery/i, "active delivery: it was handed to the peer and is not settled yet; wait for it, or resolve it once it is needs_review"],
|
|
555
|
+
[/peer busy/i, "peer busy: the peer is working, and only a queued delivery can be discarded meanwhile"],
|
|
556
|
+
[/stale delivery revision/i, "stale revision: the delivery changed since you read it; run ahub queue show again"],
|
|
557
|
+
[/unknown delivery|delivery not found/i, "unknown delivery: no such delivery id"],
|
|
558
|
+
[/already terminal|already resolved/i, "already resolved: the delivery is settled"],
|
|
559
|
+
[/already scheduled/i, "queued delivery is already scheduled: discard it, or wait for its turn"],
|
|
560
|
+
[/queue is full/i, "recipient queue is full: resolve or discard some of its queued work first"],
|
|
561
|
+
[/journal unavailable/i, "delivery journal unavailable: the hub cannot record a resolution right now"],
|
|
562
|
+
];
|
|
563
|
+
export function resolutionRefusal(error: unknown): string {
|
|
564
|
+
const text = error instanceof Error ? error.message : String(error);
|
|
565
|
+
return RESOLUTION_CAUSES.find(([pattern]) => pattern.test(text))?.[1] ?? "resolution refused: inspect ahub queue show for the delivery's state";
|
|
566
|
+
}
|
|
567
|
+
|
|
535
568
|
export async function startDaemon(opts: DaemonOptions) {
|
|
536
569
|
const projectId = opts.projectId ?? randomUUID();
|
|
537
570
|
const instanceId = opts.instanceId ?? randomUUID();
|
|
538
|
-
const startupCleanup: (() => void)[] = [];
|
|
571
|
+
const startupCleanup: (() => void | Promise<void>)[] = [];
|
|
539
572
|
let ready = false;
|
|
540
573
|
try {
|
|
541
574
|
let startupLayers: ReturnType<typeof readInstanceConfigLayers>;
|
|
@@ -555,6 +588,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
555
588
|
const adapterCatalog = createProjectAdapterCatalog(loadedConfig.adapters, externalDefinitions);
|
|
556
589
|
const configuredIds = adapterCatalog.definitions.filter(d => !builtinDefinitionOf(d.kind)).map(d => d.kind);
|
|
557
590
|
const declarations = validateInstances(loadedConfig.instances, adapterCatalog);
|
|
591
|
+
checkJcodeInstanceHomes(declarations, loadedConfig.jcode_home); // #685: one native home per Jcode, at startup and never partially
|
|
558
592
|
const extraIds = Object.keys(declarations);
|
|
559
593
|
// #450 I3: channel extras (Claude) attach from the person's terminal; the hub never starts them and their hello is admitted.
|
|
560
594
|
const channelExtras = extraIds.filter(id => adapterCatalog.definitionOf(id, declarations)?.attach?.externalHello === true);
|
|
@@ -914,6 +948,26 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
914
948
|
if (refused) log(`limits: ${env.from}: ${refused}`);
|
|
915
949
|
return refused;
|
|
916
950
|
};
|
|
951
|
+
/**
|
|
952
|
+
* #685: the provider, model and profile names an extra Jcode instance was declared with, for status, doctor and the dashboard,
|
|
953
|
+
* exactly as the person wrote them (they authenticate inside Jcode). Never a URL, a token or the native home path.
|
|
954
|
+
*/
|
|
955
|
+
const jcodeModelSource = (id: string): { modelSource?: string } => {
|
|
956
|
+
const instance = declarations[id];
|
|
957
|
+
if (!instance || definitionOf(id)?.instances?.nativeHome !== true) return {};
|
|
958
|
+
return { modelSource: [instance.provider, instance.model, instance.provider_profile && `profile ${instance.provider_profile}`].filter(Boolean).join(" ") || "native default" };
|
|
959
|
+
};
|
|
960
|
+
/**
|
|
961
|
+
* #685 scope update 2: the read-only start preflight for an extra Jcode instance. The declared home must exist as a directory the
|
|
962
|
+
* person owns (the hub never creates it, and reads or writes nothing inside it). The reason names the instance, never the path.
|
|
963
|
+
*/
|
|
964
|
+
const jcodeHomeRefusal = (id: string, home: string): string | undefined => {
|
|
965
|
+
let info: ReturnType<typeof statSync> | undefined;
|
|
966
|
+
try { info = statSync(home); } catch { /* reported below */ }
|
|
967
|
+
if (!info?.isDirectory()) return `${id}: its jcode_home is not an existing directory; the person creates it and signs in to Jcode against it first`;
|
|
968
|
+
if (typeof process.getuid === "function" && info.uid !== process.getuid()) return `${id}: its jcode_home is not owned by the person running the hub`;
|
|
969
|
+
return undefined;
|
|
970
|
+
};
|
|
917
971
|
/** This hub run's start: offline owners count from it (issue #6), split predictions read tasks since (issue #109). */
|
|
918
972
|
const hubStartedAt = Date.now();
|
|
919
973
|
// The bus exists before `Tasks`, which knows whether a queued notice still matters (issue #106).
|
|
@@ -1119,6 +1173,8 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
1119
1173
|
let modelRelay: ModelRelay | undefined;
|
|
1120
1174
|
const piReceiptLedgers = new Map<string, PiToolReceipts>();
|
|
1121
1175
|
const modelRelays = new Map<string, ModelRelay>();
|
|
1176
|
+
/** #368: the owned Jcode launch of each Jcode peer, from creation (its MCP child connects while `start()` runs, before the bus has the peer). */
|
|
1177
|
+
const jcodeLaunches = new Map<string, JcodePeer>();
|
|
1122
1178
|
const globalModelRelay = (relay: ModelRelay) => { modelRelay = relay; };
|
|
1123
1179
|
const nativeStateDir = (peer: string) => declarations[peer] ? join(opts.stateDir, "instances", peer) : opts.stateDir;
|
|
1124
1180
|
const instanceAppPorts = new Map<string, number>();
|
|
@@ -1158,8 +1214,12 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
1158
1214
|
event({ type: "conduct", peer: action.actor, action: action.action, ...(action.task === undefined ? {} : { task: action.task }), ...(action.peer ? { target: action.peer } : {}) });
|
|
1159
1215
|
try { notify(`conductor ${action.actor}: ${action.action}${action.task === undefined ? "" : ` #${action.task}`}${action.peer ? ` ${action.peer}` : ""}`); } catch { /* never throw after a task/hold write */ }
|
|
1160
1216
|
};
|
|
1217
|
+
const inboxPolling = new InboxPolling();
|
|
1161
1218
|
const board = new Board(join(opts.stateDir, "hub.db"));
|
|
1219
|
+
const codeFacts = new CodeFacts();
|
|
1220
|
+
const reviewSymbols = new Map<string, CodeFactResult<CodeSymbol[]>>();
|
|
1162
1221
|
startupCleanup.push(() => board.close());
|
|
1222
|
+
startupCleanup.push(() => codeFacts.close());
|
|
1163
1223
|
const supervision = new SupervisionFeed({
|
|
1164
1224
|
conductor: () => conductorPeer(config.roles) ?? undefined,
|
|
1165
1225
|
scope: () => config.conductor?.feed ?? "own",
|
|
@@ -1358,9 +1418,19 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
1358
1418
|
projectedReview: task => reviewProjection(task),
|
|
1359
1419
|
ownerWrites: (task, since) => ownerWriteEvidence(task, since),
|
|
1360
1420
|
ownerAccepted: task => captureOwnerWriteStart(task),
|
|
1421
|
+
prepareReviewBrief: async task => {
|
|
1422
|
+
if (tasks.isPii(task) || !task.refs.commit) return;
|
|
1423
|
+
const root = executionRoot(task.owner ?? "");
|
|
1424
|
+
const brief = buildReviewBrief(root, task.refs.commit, { privatePath: path => isDenied(path, config.local.deny) || matchesPolicy(root, path, config.pii?.paths ?? [], true), nameable: tasks.nameable });
|
|
1425
|
+
if ("unavailable" in brief) return;
|
|
1426
|
+
const key = JSON.stringify([root, brief.head, brief.mergeBase]);
|
|
1427
|
+
reviewSymbols.set(key, await codeFacts.changedSymbols(root, brief.mergeBase, brief.head));
|
|
1428
|
+
if (reviewSymbols.size > 64) reviewSymbols.delete(reviewSymbols.keys().next().value!);
|
|
1429
|
+
},
|
|
1361
1430
|
reviewBrief: task => {
|
|
1362
1431
|
const root = executionRoot(task.owner ?? "");
|
|
1363
|
-
|
|
1432
|
+
const brief = buildReviewBrief(root, task.refs.commit, { privatePath: path => isDenied(path, config.local.deny) || matchesPolicy(root, path, config.pii?.paths ?? [], true), nameable: tasks.nameable });
|
|
1433
|
+
return "unavailable" in brief ? brief : withCodeSymbols(brief, reviewSymbols.get(JSON.stringify([root, brief.head, brief.mergeBase])) ?? { state: "unknown" }, task.plan?.symbols, tasks.publicMetadataText);
|
|
1364
1434
|
},
|
|
1365
1435
|
sdlc: { config: () => lifecycleConfig, gh: (args, timeout, _cwd, bound) => sdlcGh(args, timeout, opts.cwd, bound) },
|
|
1366
1436
|
board,
|
|
@@ -1657,6 +1727,10 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
1657
1727
|
/** What quiescence was judged on, for the log when an integration waits on it. */
|
|
1658
1728
|
const stopEvidence = (peer: PeerId) => `${peer}: turn end ${turnEnded.has(peer) ? new Date(turnEnded.get(peer)!).toISOString() : "none"}, last tool call ${activeAt.has(peer) ? new Date(activeAt.get(peer)!).toISOString() : "none"}, ${bus.peers.get(peer)?.state ?? "offline"}`;
|
|
1659
1729
|
board.onChange = (t, h) => {
|
|
1730
|
+
if (!isClosedTaskState(t.state) && t.state !== "backlog") {
|
|
1731
|
+
if (t.owner) inboxPolling.reset(t.owner);
|
|
1732
|
+
if (t.reviewer) inboxPolling.reset(t.reviewer);
|
|
1733
|
+
}
|
|
1660
1734
|
turnFreeNow = undefined;
|
|
1661
1735
|
scopes = new Map();
|
|
1662
1736
|
progress.prune();
|
|
@@ -1757,6 +1831,8 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
1757
1831
|
// Somebody other than the paused peer has to be there, or the handoff would only leave its tasks without an owner.
|
|
1758
1832
|
canHandOff: (peer) => [...bus.peers.keys()].some((id) => accountOf(id) !== accountOf(peer) && ["idle", "busy"].includes(bus.stateOf(id))),
|
|
1759
1833
|
// A reading that arrived through a file carries the file's time; a stale one must not look fresh in the export.
|
|
1834
|
+
// #683 U1: pauses, resumes and derived resets go to the event log, ids and numbers only.
|
|
1835
|
+
ledger: entry => event(entry),
|
|
1760
1836
|
reading: (peer, windows, hard, at) => event({ type: "quota", peer, windows: windows.map((w) => ({ id: w.id, used: w.used, ...(w.resetsAt ? { resetsAt: w.resetsAt } : {}) })), hard, ...(Math.abs(Date.now() - at) > 1000 ? { measuredAt: new Date(at).toISOString() } : {}) }),
|
|
1761
1837
|
handoff: (peer, context, urgentOnly) => tasks.reassignForPause(peer, context, urgentOnly),
|
|
1762
1838
|
resumed: (record) => {
|
|
@@ -1783,8 +1859,17 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
1783
1859
|
notify: (line) => { notify(line); feedBudgetPauses(); },
|
|
1784
1860
|
}, accountMembers, accountOf));
|
|
1785
1861
|
budget.setRecoveryHold(recoveryActive());
|
|
1862
|
+
// ponytail: a third full read of events.jsonl at startup (after nativeStopsSeen and claudeNativeUsageSeen). Measured at about 70 ms on the real
|
|
1863
|
+
// 46 MB, 238k-line log, so it is not folded into one pass across startup. Ceiling: the log is never rotated, so the cost grows with it; when a
|
|
1864
|
+
// read passes a few hundred ms, make one `readEvents` pass feed all three sets (or keep the resets in hub.db, the #683 U2 roll-up).
|
|
1865
|
+
budget.seedResets(readEvents(join(opts.stateDir, "events.jsonl")).flatMap(e => e.type === "quota_reset" ? [{ peer: e.peer, window: e.window, ...(e.resetsAt !== undefined ? { resetsAt: e.resetsAt } : {}) }] : []));
|
|
1786
1866
|
const kimiTokens: { at: number; n: number }[] = [];
|
|
1787
1867
|
startupCleanup.push(() => budget.close());
|
|
1868
|
+
// #683 U2: events.jsonl folded into hub.db, a bounded pass at a time, so `usage` never rescans the log.
|
|
1869
|
+
const usageRollup = new UsageRollup(join(opts.stateDir, "hub.db"), join(opts.stateDir, "events.jsonl"));
|
|
1870
|
+
startupCleanup.push(() => usageRollup.close());
|
|
1871
|
+
const pollUsageRollup = () => { try { usageRollup.poll(); } catch (error) { log(`usage roll-up pass failed: ${(error as Error).message.slice(0, 120)}`); } };
|
|
1872
|
+
pollUsageRollup();
|
|
1788
1873
|
// Turns and their tokens for telemetry (issue #40). Turn ids carry the hub run, so they stay unique across restarts.
|
|
1789
1874
|
const runId = Date.now().toString(36);
|
|
1790
1875
|
let turnSeq = 0;
|
|
@@ -1944,6 +2029,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
1944
2029
|
}
|
|
1945
2030
|
const delta = tokenDeltas();
|
|
1946
2031
|
/** Tokens a peer used, as increments: Kimi reports a session total (turned into increments below), Codex increments. */
|
|
2032
|
+
let codexUsageSeq = 0; // per hub run: with the run id and peer it names one usage record
|
|
1947
2033
|
const addTokens = (peer: PeerId, n: number): number => {
|
|
1948
2034
|
const supervisionTurn = supervisionTurns.get(peer);
|
|
1949
2035
|
if (supervisionTurn && Number.isFinite(n) && n >= 0) supervisionTurn.tokens = (supervisionTurn.tokens ?? 0) + n;
|
|
@@ -2022,6 +2108,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
2022
2108
|
} catch { /* absent or unreadable native telemetry is unknown */ }
|
|
2023
2109
|
}, 1000),
|
|
2024
2110
|
setInterval(() => budget.tick(), 30_000),
|
|
2111
|
+
setInterval(pollUsageRollup, 10_000),
|
|
2025
2112
|
setInterval(() => {
|
|
2026
2113
|
if (!ready || !memoryBinding) return;
|
|
2027
2114
|
const before = memoryProblem;
|
|
@@ -2412,7 +2499,10 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
2412
2499
|
}
|
|
2413
2500
|
case "task_show": {
|
|
2414
2501
|
const task = board.get(Number(a.id));
|
|
2415
|
-
|
|
2502
|
+
if (!task) return JSON.stringify(`no task #${a.id}`);
|
|
2503
|
+
const { codeSymbols: _storedCodeSymbols, ...detail } = task;
|
|
2504
|
+
const codeSymbols = tasks.publicView(task).codeSymbols;
|
|
2505
|
+
return JSON.stringify({ ...detail, ...(codeSymbols ? { codeSymbols } : {}), symbolOverlaps: tasks.symbolOverlaps(task), flowRevision: task.history.length, reviews: board.reviews({ task: task.id }) }, null, 2);
|
|
2416
2506
|
}
|
|
2417
2507
|
case "task_assign":
|
|
2418
2508
|
case "task_assign_checked": {
|
|
@@ -2496,6 +2586,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
2496
2586
|
else delete defaultSources[peer];
|
|
2497
2587
|
}
|
|
2498
2588
|
};
|
|
2589
|
+
const actionBudgets = new ActionBudgets(() => config.action_policy, contextSession);
|
|
2499
2590
|
const permissionMode = (peer: string): PermissionMode => permissionModes.get(peer) ?? "ask";
|
|
2500
2591
|
const permissionModeSources = new Map(Object.entries(defaultSources).map(([peer, value]) => [peer, value.source]));
|
|
2501
2592
|
const unattendedPeers = new Set<string>();
|
|
@@ -2504,6 +2595,12 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
2504
2595
|
const claudePermissionGenerations = new Map<string, string>();
|
|
2505
2596
|
const permissions = new Map<string, { push: string; peer: string; tool?: string; createdAt: number; expiresAt: number; done: (optionId: string | undefined, surface?: "console" | "dashboard" | "terminal", reason?: "expired" | "cancelled") => boolean }>();
|
|
2506
2597
|
let stopping = false;
|
|
2598
|
+
const releaseAuthorizations = new ReleaseAuthorizations(opts.cwd, {
|
|
2599
|
+
docs: opts.releaseDocEvidence,
|
|
2600
|
+
gh: (args, timeout) => sdlcGh(args, timeout, opts.cwd),
|
|
2601
|
+
current: () => !stopping && !recoveryActive() && !bus.isRecoveryHeld,
|
|
2602
|
+
record: receipt => event({ type: "release_authorization", by: "user", authorization: receipt.id, commit: receipt.commit, version: receipt.version, docsDigest: receipt.docs.digest, documents: receipt.docs.documents }),
|
|
2603
|
+
});
|
|
2507
2604
|
const terminalReads = terminalOwnerReads();
|
|
2508
2605
|
const terminalLaunches = new TerminalLaunches({
|
|
2509
2606
|
owner: row => {
|
|
@@ -2788,10 +2885,11 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
2788
2885
|
...(dashboard ? { uiOrigin: dashboard.origin } : {}),
|
|
2789
2886
|
codexProxyPort: opts.codexProxyPort,
|
|
2790
2887
|
peers: Object.fromEntries(
|
|
2791
|
-
peerIds.map((id) => { const p = bus.peers.get(id); const summary = bus.queueSummary(id); const context = contexts.view(id, contextSession(id), !!p && ["idle", "busy", "paused"].includes(bus.stateOf(id))); return [id, { state: bus.stateOf(id), ...(!membershipAllowed(id) ? { rostered: false, rosterNote: "off roster" } : {}), ...permissionStatus(id, p), ...(isCodexPeer(p) && p.statusNotice ? { statusNotice: p.statusNotice } : {}), ...(context.source !== null || context.measuredAt !== null ? { context } : {}), queued: bus.queued(id), ...(p ? {} : { attached: false }), ...(summary.needsReview ? { needsReview: summary.needsReview } : {}), ...(summary.liveAccepted ? { liveAccepted: summary.liveAccepted, settlementNote: "awaiting adapter completion (Claude: correlated reply or hub_delivery_done); task state is independent" } : {}), ...queueHoldStatus(id, summary.heldBy), ...(summary.oldestQueuedAt !== undefined ? { oldestQueuedAt: summary.oldestQueuedAt } : {}), ...(bus.queuedImportant(id) ? { queuedImportant: bus.queuedImportant(id) } : {}), ...importantWaitingStatus(id, p), ...pausedNote(id), ...(isLocalPeer(p) && p.lastServedBy ? { servedBy: p.lastServedBy } : {}), ...(isWsPeer(p) && p.claiming ? { claiming: true } : {}), ...(isWsPeer(p) && p.pullOnly ? { pullOnly: true, toolsOnly: definitionOf(id)?.attach?.pullOnlyNotice ?? "tools-only: messages wait for hub_inbox; for pushes restart Claude with ahub claude", lastInboxAt: p.lastInboxAt ?? null } : {}), ...(isPiPeer(p) ? { requestedModel: p.getRequestedModel(), backends: modelRelays.get(id)?.status().backends ?? [] } : {}) }]; }),
|
|
2888
|
+
peerIds.map((id) => { const p = bus.peers.get(id); const summary = bus.queueSummary(id); const context = contexts.view(id, contextSession(id), !!p && ["idle", "busy", "paused"].includes(bus.stateOf(id))); return [id, { state: bus.stateOf(id), ...(!membershipAllowed(id) ? { rostered: false, rosterNote: "off roster" } : {}), ...permissionStatus(id, p), ...(isCodexPeer(p) && p.statusNotice ? { statusNotice: p.statusNotice } : {}), ...(context.source !== null || context.measuredAt !== null ? { context } : {}), queued: bus.queued(id), ...(p ? {} : { attached: false }), ...(summary.needsReview ? { needsReview: summary.needsReview } : {}), ...(summary.liveAccepted ? { liveAccepted: summary.liveAccepted, settlementNote: "awaiting adapter completion (Claude: correlated reply or hub_delivery_done); task state is independent" } : {}), ...queueHoldStatus(id, summary.heldBy), ...(summary.oldestQueuedAt !== undefined ? { oldestQueuedAt: summary.oldestQueuedAt } : {}), ...(bus.queuedImportant(id) ? { queuedImportant: bus.queuedImportant(id) } : {}), ...importantWaitingStatus(id, p), ...pausedNote(id), ...jcodeModelSource(id), ...(isLocalPeer(p) && p.lastServedBy ? { servedBy: p.lastServedBy } : {}), ...(isWsPeer(p) && p.claiming ? { claiming: true } : {}), ...(isWsPeer(p) && p.pullOnly ? { pullOnly: true, toolsOnly: definitionOf(id)?.attach?.pullOnlyNotice ?? "tools-only: messages wait for hub_inbox; for pushes restart Claude with ahub claude", lastInboxAt: p.lastInboxAt ?? null } : {}), ...(isPiPeer(p) ? { requestedModel: p.getRequestedModel(), backends: modelRelays.get(id)?.status().backends ?? [] } : {}) }]; }),
|
|
2792
2889
|
),
|
|
2793
2890
|
taskWip: Object.fromEntries(peerIds.map(id => [id, { wip: tasks.wip(id), wipLimit: tasks.wipLimit(id) }])),
|
|
2794
2891
|
boardReadCapabilities: { releaseFilter: true, columnMetadata: true },
|
|
2892
|
+
actionPolicyInfo: Object.fromEntries(peerIds.map(id => [id, actionBudgets.status(id, false)])),
|
|
2795
2893
|
roleInfo: Object.fromEntries(peerIds.map(id => [id, { flowRoles: config.roles[id] ?? [], catalogRoles: roleState(id), ...(config.roles[id]?.includes("planner") ? { planner: plannerSource } : {}) }])),
|
|
2796
2894
|
planner: { peers: plannerPeers(), source: plannerSource },
|
|
2797
2895
|
...(pendingPermissionDefaults.size ? { permissionDefaults: [...pendingPermissionDefaults].map(([peer, source]) => ({ peer, mode: "never-ask", source })) } : {}),
|
|
@@ -3706,12 +3804,24 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
3706
3804
|
}
|
|
3707
3805
|
if (adapter?.start.daemonPort === "owned-acp") {
|
|
3708
3806
|
if (!jcodeMcpReady(opts.cwd, opts.stateDir, SERVER_JS)) return { ok: false, error: "Jcode project MCP entry missing or stale; the person runs ahub jcode to configure it" };
|
|
3709
|
-
|
|
3710
|
-
|
|
3807
|
+
// #691: name a version refusal before the owned daemon starts; the ACP identity check stays the binding one.
|
|
3808
|
+
const jcodeVerdict = await jcodeVersionAsync(config.jcode_cmd, executionCwd);
|
|
3809
|
+
if (!jcodeVerdict.ok) return { ok: false, error: jcodeVerdict.reason };
|
|
3810
|
+
if (jcodeVerdict.note) log(jcodeVerdict.note);
|
|
3811
|
+
// #685: an extra instance runs its own native home and model fields; the base peer keeps jcode_home and the global provider profile.
|
|
3812
|
+
const instance = declarations[peer];
|
|
3813
|
+
// #685 scope update 2: the person authenticates inside Jcode against that home. The hub passes -p, -m and --provider-profile as
|
|
3814
|
+
// written and reads or writes no credential; the only preflight is that the declared home is a directory the person owns.
|
|
3815
|
+
if (instance?.jcode_home) { const refusal = jcodeHomeRefusal(peer, instance.jcode_home); if (refusal) return { ok: false, error: refusal }; }
|
|
3816
|
+
const jcode: JcodePeer = adapter!.start.create!({ instance: peer, projectRoot: opts.cwd, stateDir: opts.stateDir, kindConfig: config, permission: permissionMode(peer), mcpServers: [], daemon: DAEMON_PORTS["owned-acp"]({ peer, command: config.jcode_cmd, home: instance?.jcode_home ?? config.jcode_home,
|
|
3817
|
+
providerProfile: (args.providerProfile ?? instance?.provider_profile ?? config.jcode_provider_profile) || undefined,
|
|
3818
|
+
...(instance?.provider ? { provider: instance.provider } : {}),
|
|
3819
|
+
...(args.model ?? instance?.model ? { launchModel: (args.model ?? instance?.model)! } : {}), ...(args.sessionId ? { resumeSessionId: args.sessionId } : {}), cwd: executionCwd, watchdogMs: config.watchdog_ms,
|
|
3711
3820
|
preamble: sessionRoleContract(peer), log: () => log("Jcode native diagnostic: details withheld") }) }) as JcodePeer;
|
|
3821
|
+
jcodeLaunches.set(peer, jcode);
|
|
3712
3822
|
recoveryTaskPreface(peer); await ensurePreface(peer);
|
|
3713
3823
|
try { await jcode.start(); await reconcileNativeStart(peer, jcode); }
|
|
3714
|
-
catch { await jcode.stop().catch(() => {}); throw new Error("Jcode startup not verified; check native version, provider profile, MCP store and owned daemon (details withheld)"); }
|
|
3824
|
+
catch { await jcode.stop().catch(() => {}); if (jcodeLaunches.get(peer) === jcode) jcodeLaunches.delete(peer); throw new Error("Jcode startup not verified; check native version, provider profile, MCP store and owned daemon (details withheld)"); }
|
|
3715
3825
|
return { ok: true, policy: "unmanaged" };
|
|
3716
3826
|
}
|
|
3717
3827
|
if (adapter?.start.acpCommand) {
|
|
@@ -3796,7 +3906,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
3796
3906
|
if (configuredIds.includes(adapter.kind) && owner.pendingOwnedProcess) bus.add(owner); // Retain the failed owner for explicit stop retry; never replace its live child.
|
|
3797
3907
|
if (configuredIds.includes(adapter.kind)) throw new Error(`${peer}: ACP startup identity, session, permission or owned-group verification failed (details withheld)`);
|
|
3798
3908
|
if (error instanceof QwenIdentityError) throw error;
|
|
3799
|
-
throw new Error(
|
|
3909
|
+
throw new Error(`${adapter.displayName} could not reach its provider or complete its startup handshake; verify the provider connection and native CLI${isCompatId(adapter.kind) ? ` (${describe(adapter.kind)})` : ""}; see ahub agents (details withheld)`);
|
|
3800
3910
|
}
|
|
3801
3911
|
return { ok: true };
|
|
3802
3912
|
}
|
|
@@ -3807,6 +3917,12 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
3807
3917
|
admitAttach: () => membershipAllowed(peer),
|
|
3808
3918
|
permissionMode: permissionMode(peer),
|
|
3809
3919
|
onTokens: (added) => void addTokens(peer, added),
|
|
3920
|
+
// #683 U1: the same update split into Codex's own kinds, next to the unchanged `tokens` event. One record per update:
|
|
3921
|
+
// the id names the hub run, peer and a per-run sequence, so it is unique across restarts. It never carries the native thread: usage telemetry has no session id (docs/events.md).
|
|
3922
|
+
onTokenBreakdown: delta => {
|
|
3923
|
+
event({ type: "usage", peer, source: "codex_appserver", id: `codex:${runId}:${peer}:${++codexUsageSeq}`, inputTokens: delta.input, cacheReadTokens: delta.cachedInput,
|
|
3924
|
+
outputTokens: delta.output, reasoningTokens: delta.reasoning, totalTokens: delta.total, ...taskAttribution(peer) });
|
|
3925
|
+
},
|
|
3810
3926
|
contextReader: adapter.budget?.context,
|
|
3811
3927
|
onContext: adapter.budget?.context ? (reading) => contexts.report(peer, reading, contextSession(peer)) : undefined,
|
|
3812
3928
|
// #215: before the thread's first turn_start, so the planner can tell which turns belong to which thread.
|
|
@@ -4154,7 +4270,8 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
4154
4270
|
try { await reviewer.start(); }
|
|
4155
4271
|
catch (error) {
|
|
4156
4272
|
await reviewer.stop().catch(() => {});
|
|
4157
|
-
|
|
4273
|
+
// #691: a version refusal names only versions and the trust command, so it is shown for every backend.
|
|
4274
|
+
const reason = error instanceof Error && (backend.nativePolicy || error.message.startsWith(VERSION_REFUSAL)) ? sanitize(error.message).slice(0, 1000)
|
|
4158
4275
|
: "reviewer isolation unavailable; inspect the verified CLI version and isolation requirements";
|
|
4159
4276
|
reviewerUnavailable.set(peer, reason);
|
|
4160
4277
|
return { ok: false, error: reason };
|
|
@@ -4372,7 +4489,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
4372
4489
|
tasks: boardTasks.map((task) => {
|
|
4373
4490
|
const view = tasks.publicView(task);
|
|
4374
4491
|
// Refs and history can themselves quote private text. The dashboard needs neither.
|
|
4375
|
-
return { id: task.id, title: view.title, detail: view.detail, class: task.class, ...(task.profile ? { profile: task.profile, profileClass: task.profileClass } : {}), ...(task.role ? { role: task.role } : {}), state: task.state, owner: task.owner, reviewer: task.reviewer, deps: view.deps ?? [], ...(task.reserved ? { reserved: task.reserved } : {}), planningRevision: task.planningRevision ?? 0, rank: task.rank ?? null, priority: task.priority ?? null, release: task.release ?? null, plannedStart: task.plannedStart ?? null, due: task.due ?? null, pinned: task.pinned === true, actualDates: view.actualDates, ...(view.cancelled ? { cancelled: view.cancelled } : {}), ...(view.external ? { external: view.external } : {}), started: taskHasStarted(task), urgent: task.signals.includes("urgent"), updated: task.updated, reworkRound: task.history.filter(entry => entry.event === "changes_requested").length, checking: tasks.isChecking(task.id), ...(view.lifecycle ? { lifecycle: view.lifecycle } : {}) };
|
|
4492
|
+
return { id: task.id, title: view.title, detail: view.detail, class: task.class, ...(task.profile ? { profile: task.profile, profileClass: task.profileClass } : {}), ...(task.role ? { role: task.role } : {}), state: task.state, owner: task.owner, reviewer: task.reviewer, deps: view.deps ?? [], ...(task.reserved ? { reserved: task.reserved } : {}), planningRevision: task.planningRevision ?? 0, rank: task.rank ?? null, priority: task.priority ?? null, release: task.release ?? null, plannedStart: task.plannedStart ?? null, due: task.due ?? null, pinned: task.pinned === true, actualDates: view.actualDates, ...(view.codeSymbols ? { codeSymbols: view.codeSymbols } : {}), ...(view.cancelled ? { cancelled: view.cancelled } : {}), ...(view.external ? { external: view.external } : {}), started: taskHasStarted(task), urgent: task.signals.includes("urgent"), updated: task.updated, reworkRound: task.history.filter(entry => entry.event === "changes_requested").length, checking: tasks.isChecking(task.id), ...(view.lifecycle ? { lifecycle: view.lifecycle } : {}) };
|
|
4376
4493
|
}),
|
|
4377
4494
|
links: links.filter(link => link.tasks.every(id => { const task = board.get(id); return task && !tasks.isPii(task); })),
|
|
4378
4495
|
budget: Object.fromEntries(peers.map((id) => [id, { ...(quota[accountOf(id)] ?? { windows: [] }), context: contexts.view(id, contextSession(id), bus.peers.has(id) && ["idle", "busy", "paused"].includes(bus.stateOf(id))) }])),
|
|
@@ -4487,7 +4604,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
4487
4604
|
settledHolderChange = JSON.stringify(oldHolders) !== JSON.stringify(newHolders);
|
|
4488
4605
|
if (settledHolderChange) effect = `planner ${JSON.stringify(oldHolders.planners)} -> ${JSON.stringify(newHolders.planners)}; conductor ${JSON.stringify(oldHolders.conductors)} -> ${JSON.stringify(newHolders.conductors)}`;
|
|
4489
4606
|
}
|
|
4490
|
-
const input = { key, before, acceptedFallback, settledHolderChange, after: machine && rawChanged ? rawAfter : after, source: route ? "routing" as const : "config" as const, tracked, authority, guard, confirmed: !!scope || confirmed.includes(key) };
|
|
4607
|
+
const input = { key, before, acceptedFallback, settledHolderChange, after: machine && rawChanged ? rawAfter : after, source: route ? "routing" as const : "config" as const, sourceFile: origin?.name, tracked, authority, guard, confirmed: !!scope || confirmed.includes(key) };
|
|
4491
4608
|
const decision = authorizeConfigLeaf(input);
|
|
4492
4609
|
if (!decision.allowed) {
|
|
4493
4610
|
const next = `${decision.reason}; review and run ahub settings reload, or revert this key${machine && tracked !== false ? "; move machine-local declarations to an untracked local file first" : ""}`;
|
|
@@ -4603,6 +4720,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
4603
4720
|
if (change.applies === "restart" || change.key === "roles" || change.key.startsWith("roles.")) continue;
|
|
4604
4721
|
putConfig(config, change.key, change.after);
|
|
4605
4722
|
}
|
|
4723
|
+
if (changes.some(change => change.key === "action_policy" || change.key.startsWith("action_policy."))) actionBudgets.policyChanged();
|
|
4606
4724
|
if (rolesChanged) { policyRoles = structuredClone(prepared.running.roles); settlePlanner(); }
|
|
4607
4725
|
for (const change of changes.filter(change => change.key === "permission_modes" || change.key.startsWith("permission_modes."))) {
|
|
4608
4726
|
for (const peer of change.key === "permission_modes" ? SUPPORTED_PEERS : [change.key.split(".")[1]!]) changedPermissionDefaults.add(peer);
|
|
@@ -4936,7 +5054,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
4936
5054
|
if (a.action === "sdlc_queue" || a.action === "sdlc_release") {
|
|
4937
5055
|
const liveIssues = a.liveIssues ?? [];
|
|
4938
5056
|
if (typeof a.repo !== "string" || !Array.isArray(liveIssues) || liveIssues.length > 32) return bad;
|
|
4939
|
-
const report = a.action === "sdlc_queue" ? await tasks.deliveryQueue(a.repo) : await tasks.releaseReadiness(a.repo, liveIssues);
|
|
5057
|
+
const report = a.action === "sdlc_queue" ? await tasks.deliveryQueue(a.repo) : releaseAuthorizations.report(await tasks.releaseReadiness(a.repo, liveIssues));
|
|
4940
5058
|
return { ok: true, report };
|
|
4941
5059
|
}
|
|
4942
5060
|
switch (a.action) {
|
|
@@ -5313,6 +5431,12 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
5313
5431
|
c.peer = String(msg.peer ?? "");
|
|
5314
5432
|
if (!PEER_ID.test(c.peer) || c.peer === USER || c.peer === "hub") return sock.close(4403, "peer id is reserved or malformed");
|
|
5315
5433
|
if (!membershipAllowed(c.peer)) return sock.close(4000, "peer is off roster; retry when a person adds it");
|
|
5434
|
+
// #368: a hub-tools socket for an owned Jcode (a project-file MCP entry, which names no peer) is admitted only with the launch id of that
|
|
5435
|
+
// peer's current owned launch. A forged AGENTHUB_PEER_ID in some other process has no such id, so it never acts as that peer.
|
|
5436
|
+
if (definitionOf(c.peer)?.start.daemonPort === "owned-acp") {
|
|
5437
|
+
const owner = jcodeLaunches.get(c.peer);
|
|
5438
|
+
if (!owner || !owner.ownsLaunch(msg.launchId)) return sock.close(4403, "this process is not an owned launch of that peer; start it with ahub jcode");
|
|
5439
|
+
}
|
|
5316
5440
|
} else {
|
|
5317
5441
|
c.peer = String(msg.peer ?? defaultChannelPeer(undefined));
|
|
5318
5442
|
if (definitionOf(c.peer)?.transport === "ws-pull" && msg.channel !== false) return sock.close(4403, "this native peer supports pull-only attachment; use hub_inbox");
|
|
@@ -5376,6 +5500,31 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
5376
5500
|
if (msg.peer !== undefined && (typeof msg.peer !== "string" || !PEER_ID.test(msg.peer))) return void reply({ ok: false, error: "invalid peer" });
|
|
5377
5501
|
return void reply({ ok: true, deliveries: bus.queueList(msg.peer).map((row) => queueView(row)) });
|
|
5378
5502
|
}
|
|
5503
|
+
if (msg.op === "discard") {
|
|
5504
|
+
// #713: the person's bulk clear of one peer's backlog. Without `confirmed` it only counts; with it, every queued (or needs_review) delivery of that peer is discarded
|
|
5505
|
+
// through the same `resolveDelivery` as one by one, each with the reason, so the journal records each as a `resolve ... discard`. Push peers' queues are never touched
|
|
5506
|
+
// because only the named peer is read. Refused while recovery or shutdown holds queue mutations, like every queue resolution.
|
|
5507
|
+
if (typeof msg.peer !== "string" || !PEER_ID.test(msg.peer)) return void reply({ ok: false, error: "peer must be a peer id" });
|
|
5508
|
+
const state = msg.state === undefined ? "queued" : msg.state;
|
|
5509
|
+
if (state !== "queued" && state !== "needs_review") return void reply({ ok: false, error: "state is queued or needs_review" });
|
|
5510
|
+
const rows = bus.queueList(msg.peer).filter((row) => row.state === state);
|
|
5511
|
+
if (msg.confirmed !== true) return void reply({ ok: true, op: "discard", peer: msg.peer, state, count: rows.length, discarded: 0, refused: 0 });
|
|
5512
|
+
if (typeof msg.reason !== "string" || !msg.reason.trim() || msg.reason.length > 2000) return void reply({ ok: false, error: "discard needs a reason" });
|
|
5513
|
+
if (recoveryActive() || stopping) return void reply({ ok: false, error: "recovery or shutdown is holding queue mutations" });
|
|
5514
|
+
let discarded = 0; const refused: Record<string, number> = {};
|
|
5515
|
+
for (const row of rows) {
|
|
5516
|
+
// A queued row with no journal record of its own carries the journal's global revision, which every discard moves: read the row's current revision just before
|
|
5517
|
+
// discarding it, so the 12th discard is not refused as stale because of the 11 before it. A row that is gone or changed state meanwhile is counted as refused.
|
|
5518
|
+
const current = bus.queueShow(row.id) ?? bus.queueList(msg.peer).find((r) => r.id === row.id);
|
|
5519
|
+
try {
|
|
5520
|
+
if (!current || current.state !== state) throw new Error("unknown delivery");
|
|
5521
|
+
bus.resolveDelivery(row.id, current.revision, "discard", msg.reason.trim()); discarded++;
|
|
5522
|
+
}
|
|
5523
|
+
catch (error) { const why = resolutionRefusal(error).split(":")[0]!; refused[why] = (refused[why] ?? 0) + 1; }
|
|
5524
|
+
}
|
|
5525
|
+
writeStatus();
|
|
5526
|
+
return void reply({ ok: true, op: "discard", peer: msg.peer, state, count: rows.length, discarded, refused: rows.length - discarded, ...(rows.length - discarded ? { refusedBy: refused } : {}) });
|
|
5527
|
+
}
|
|
5379
5528
|
if (typeof msg.id !== "string" || !msg.id || msg.id.length > 256) return void reply({ ok: false, error: "invalid delivery ID" });
|
|
5380
5529
|
if (msg.op === "show") {
|
|
5381
5530
|
const row = bus.queueShow(msg.id);
|
|
@@ -5387,7 +5536,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
5387
5536
|
bus.resolveDelivery(msg.id, msg.revision, msg.action, msg.reason.trim());
|
|
5388
5537
|
writeStatus();
|
|
5389
5538
|
return void reply({ ok: true, delivery: queueView(bus.queueShow(msg.id)) });
|
|
5390
|
-
} catch { return void reply({ ok: false, error:
|
|
5539
|
+
} catch (error) { return void reply({ ok: false, error: resolutionRefusal(error) }); }
|
|
5391
5540
|
}
|
|
5392
5541
|
case "delivery_holds": {
|
|
5393
5542
|
if (!c.peer || (c.role !== "peer" && c.role !== "tools")) return void reply({ ok: false, error: "held metadata requires a peer identity" });
|
|
@@ -5413,9 +5562,15 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
5413
5562
|
const envs = bus.pull(peer.id);
|
|
5414
5563
|
if (!envs) return void reply({ ok: false, error: queueHold(peer.id) ?? (bus.isRecoveryHeld ? "recovery is holding deliveries" : pauseReason(peer.id)) });
|
|
5415
5564
|
peer.lastInboxAt = Date.now();
|
|
5565
|
+
let polling: ReturnType<InboxPolling["read"]>;
|
|
5566
|
+
try {
|
|
5567
|
+
const assigned = peer.pullOnly && board.statusTasks().some(task => !isClosedTaskState(task.state) && task.state !== "backlog" && (task.owner === peer.id || task.reviewer === peer.id));
|
|
5568
|
+
polling = peer.pullOnly ? inboxPolling.read(peer.id, envs.length === 0, assigned, peer.lastInboxAt) : undefined;
|
|
5569
|
+
if (polling?.notice) notify(`${peer.id} is polling an empty inbox with no task (${polling.count} empty reads in ${polling.minutes} min)`);
|
|
5570
|
+
} catch { polling = undefined; } // The pull already completed: advice must never discard its envelopes.
|
|
5416
5571
|
writeStatus();
|
|
5417
5572
|
if (envs.length) log(`${peer.id} read ${envs.length} queued message(s) through hub_inbox`);
|
|
5418
|
-
return void reply({ ok: true, envs, waiting: bus.queued(peer.id), waitingImportant: bus.queuedImportant(peer.id) });
|
|
5573
|
+
return void reply({ ok: true, envs, waiting: bus.queued(peer.id), waitingImportant: bus.queuedImportant(peer.id), ...(polling ? { pollingAdvice: { count: polling.count, minutes: polling.minutes } } : {}) });
|
|
5419
5574
|
} catch { return void reply({ ok: false, error: "delivery journal unavailable" }); }
|
|
5420
5575
|
}
|
|
5421
5576
|
case "delivery_complete": {
|
|
@@ -5749,11 +5904,31 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
5749
5904
|
return void reply({ t: "ui", ok: false, error: "could not start the dashboard" });
|
|
5750
5905
|
}
|
|
5751
5906
|
case "sdlc": {
|
|
5907
|
+
if (msg.op === "release-guard" || msg.op === "release-guard-hook") {
|
|
5908
|
+
if (c.role !== "console" && c.role !== "tools" && c.role !== "peer" || c.peer && (!!turns.get(c.peer)?.private || contextPii(c.peer))) return void reply({ ok: false, error: "release guard unavailable for this caller" });
|
|
5909
|
+
if (msg.op === "release-guard") {
|
|
5910
|
+
const parsed = parseReleaseGuardArgv(msg.argv);
|
|
5911
|
+
if (!parsed.ok) return void reply({ ok: false, error: parsed.reason });
|
|
5912
|
+
void releaseAuthorizations.guardRequest(parsed.request).then(reply, () => reply({ ok: false, error: "release guard evidence unavailable" }));
|
|
5913
|
+
} else {
|
|
5914
|
+
const parsed = parseReleasePushUpdates(msg.remoteName, msg.remoteUrl, msg.stdin);
|
|
5915
|
+
if (!parsed.ok) return void reply({ ok: false, error: parsed.reason });
|
|
5916
|
+
reply(releaseAuthorizations.guardPush(parsed.remoteName, parsed.remoteUrl, parsed.updates));
|
|
5917
|
+
}
|
|
5918
|
+
return;
|
|
5919
|
+
}
|
|
5752
5920
|
if (c.role !== "console") return void reply({ ok: false, error: "SDLC readback is console only; task owners record ids through hub_sdlc_record" });
|
|
5921
|
+
if (msg.op === "authorize-release") {
|
|
5922
|
+
void releaseAuthorizations.authorize(USER, msg.version, msg.commit).then(
|
|
5923
|
+
authorization => reply({ ok: true, authorization }),
|
|
5924
|
+
error => reply({ ok: false, error: releaseAuthorizationError(error) }),
|
|
5925
|
+
);
|
|
5926
|
+
return;
|
|
5927
|
+
}
|
|
5753
5928
|
if (msg.op === "queue" || msg.op === "release") {
|
|
5754
5929
|
if (typeof msg.repo !== "string" || !Array.isArray(msg.liveIssues ?? []) || (msg.liveIssues ?? []).length > 32) return void reply({ ok: false, error: "invalid delivery readback arguments" });
|
|
5755
5930
|
const read = msg.op === "queue" ? tasks.deliveryQueue(msg.repo) : tasks.releaseReadiness(msg.repo, msg.liveIssues ?? []);
|
|
5756
|
-
void read.then(report => reply({ ok: true, report }), () => reply({ ok: false, error: "delivery readback unavailable" }));
|
|
5931
|
+
void read.then(report => reply({ ok: true, report: msg.op === "release" ? releaseAuthorizations.report(report as Awaited<ReturnType<typeof tasks.releaseReadiness>>) : report }), () => reply({ ok: false, error: "delivery readback unavailable" }));
|
|
5757
5932
|
return;
|
|
5758
5933
|
}
|
|
5759
5934
|
if (msg.op === "export") {
|
|
@@ -5788,7 +5963,11 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
5788
5963
|
if (msg.id !== undefined && (!Number.isSafeInteger(msg.id) || msg.id <= 0 || !selected.length)) return void reply({ ok: false, error: "invalid lifecycle task id" });
|
|
5789
5964
|
void (async () => {
|
|
5790
5965
|
const rows = [];
|
|
5791
|
-
for (const task of selected.slice(0, 100))
|
|
5966
|
+
for (const task of selected.slice(0, 100)) {
|
|
5967
|
+
const status = await tasks.lifecycleStatus(task.id, msg.id !== undefined);
|
|
5968
|
+
const current = board.get(task.id) ?? task;
|
|
5969
|
+
rows.push({ id: task.id, title: tasks.publicTitle(current), ...(tasks.isPii(current) ? {} : { receipt: tasks.publicView(current).sdlc }), ...status });
|
|
5970
|
+
}
|
|
5792
5971
|
reply({ ok: true, mode: lifecycleConfig.mode, rows, truncated: selected.length > 100 });
|
|
5793
5972
|
})().catch(() => reply({ ok: false, error: "lifecycle readback unavailable" }));
|
|
5794
5973
|
return;
|
|
@@ -5964,6 +6143,41 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
5964
6143
|
budget.report(String(msg.set.peer), [{ id: msg.set.window === "week" ? "week" : "5h", used, ...(msg.set.resetsInMs ? { resetsAt: Date.now() + Number(msg.set.resetsInMs) } : {}), source: "ahub budget set" }]);
|
|
5965
6144
|
}
|
|
5966
6145
|
return void reply({ t: "budget", ok: true, budget: budget.status(), gate: config.budget.gate });
|
|
6146
|
+
case "usage": {
|
|
6147
|
+
// #683 U2: the console, the person and a configured planner read usage; every other agent is refused. Always answered (rid).
|
|
6148
|
+
const planner = c.role === "tools" && !!c.peer && !!config.roles[c.peer]?.includes("planner");
|
|
6149
|
+
if (c.role !== "console" && !planner) return void reply({ t: "usage", ok: false, error: "usage is read by the person and a configured planner" });
|
|
6150
|
+
if (planner && c.peer && turns.get(c.peer)?.private) return void reply({ t: "usage", ok: false, error: "usage is unavailable during a private turn" });
|
|
6151
|
+
const op = msg.op === undefined ? "query" : msg.op;
|
|
6152
|
+
const num = (v: unknown): number | undefined | null => v === undefined ? undefined : typeof v === "number" && Number.isFinite(v) && v >= 0 ? v : null;
|
|
6153
|
+
const since = num(msg.since), until = num(msg.until);
|
|
6154
|
+
if (since === null || until === null) return void reply({ t: "usage", ok: false, error: "since and until are epoch milliseconds" });
|
|
6155
|
+
if (msg.task !== undefined && !(Number.isSafeInteger(msg.task) && (msg.task as number) > 0)) return void reply({ t: "usage", ok: false, error: "task is a positive task id" });
|
|
6156
|
+
try {
|
|
6157
|
+
// Fold what the log has now, within a time budget: a hub that has just started is still behind a large log, and an answer built from the
|
|
6158
|
+
// part folded so far would read as "no records" for a peer that did use tokens. `behind` says so when the budget ran out (#683 U3).
|
|
6159
|
+
// ponytail: catchUp runs synchronously on the daemon loop, so a request on a log far behind stalls it for up to this budget (about 250 ms
|
|
6160
|
+
// folds the real 47 MB log). Ceiling: a log several times larger answers `behind` and finishes over the 10 s polls; move the fold to a worker if a stall is felt.
|
|
6161
|
+
const behind = !usageRollup.catchUp(250);
|
|
6162
|
+
const reply_ = (body: Record<string, unknown>) => reply({ ...body, ...(behind ? { behind: true } : {}) });
|
|
6163
|
+
if (op === "limits") {
|
|
6164
|
+
if (msg.peer !== undefined && (typeof msg.peer !== "string" || !PEER_ID.test(msg.peer))) return void reply({ t: "usage", ok: false, error: "peer must be a peer id" });
|
|
6165
|
+
if (msg.limit !== undefined && !(Number.isInteger(msg.limit) && (msg.limit as number) >= 1 && (msg.limit as number) <= LIMITS_MAX)) return void reply({ t: "usage", ok: false, error: `limit is an integer from 1 to ${LIMITS_MAX}` });
|
|
6166
|
+
const status = budget.status();
|
|
6167
|
+
// The pauses the hub holds now: an event-open pause it no longer holds (an `ahub reset` cleared it) is reported as ended, not open.
|
|
6168
|
+
const live = new Set(Object.entries(status).filter(([, v]) => v.paused).map(([peer]) => peer));
|
|
6169
|
+
return void reply_({ t: "usage", ok: true, op, limits: usageRollup.limits(msg.peer, { ...(msg.limit !== undefined ? { limit: msg.limit as number } : {}), live }), current: publicPeerBudget(status, text => tasks.nameable(text)) });
|
|
6170
|
+
}
|
|
6171
|
+
if (op === "total") return void reply_({ t: "usage", ok: true, op, total: usageRollup.total() });
|
|
6172
|
+
if (op !== "query") return void reply({ t: "usage", ok: false, error: "op is query, limits or total" });
|
|
6173
|
+
const by = (msg.by ?? "peer") as UsageGroup;
|
|
6174
|
+
if (!USAGE_GROUPS.includes(by)) return void reply({ t: "usage", ok: false, error: `by is one of ${USAGE_GROUPS.join(", ")}` });
|
|
6175
|
+
// issue, pr and release are the board's current links of each task (ids only, never a title).
|
|
6176
|
+
const board_ = new Map(board.list().map(t => [t.id, t]));
|
|
6177
|
+
const links = (id: number) => { const t = board_.get(id); return t ? { ...(t.sdlc?.issue ? { issue: t.sdlc.issue } : {}), ...(t.sdlc?.pr ? { pr: t.sdlc.pr } : {}), ...(t.release ? { release: t.release } : {}) } : undefined; };
|
|
6178
|
+
return void reply_({ t: "usage", ok: true, op, usage: usageRollup.query({ by, ...(since !== undefined ? { since } : {}), ...(until !== undefined ? { until } : {}), ...(msg.task !== undefined ? { task: msg.task as number } : {}) }, links) });
|
|
6179
|
+
} catch (error) { return void reply({ t: "usage", ok: false, error: `usage unavailable: ${(error as Error).message.slice(0, 120)}` }); }
|
|
6180
|
+
}
|
|
5967
6181
|
case "permit":
|
|
5968
6182
|
if (c.role !== "console") return void reply({ t: "permit", ok: false, error: "permit is a console command" });
|
|
5969
6183
|
return void reply({ t: "permit", ok: permissions.get(String(msg.id))?.done(msg.option ? String(msg.option) : undefined, msg.surface === "console" ? "console" : "terminal") === true });
|
|
@@ -5992,6 +6206,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
5992
6206
|
stopping = true;
|
|
5993
6207
|
terminalLaunches.clear();
|
|
5994
6208
|
// Start cancellation immediately; a failed read cleanup must not strand the other owned peers or finalization.
|
|
6209
|
+
const codeFactsStopped = codeFacts.close().catch(() => { log("code facts cleanup failed during shutdown"); });
|
|
5995
6210
|
const terminalReadsStopped = terminalReads.stop().catch(() => { log("terminal owner query cleanup failed during shutdown"); });
|
|
5996
6211
|
runnerRefreshAbort.abort();
|
|
5997
6212
|
// A stop someone asked for is not a crash, even if it then runs past the shutdown deadline: forget the sessions now.
|
|
@@ -6007,7 +6222,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
6007
6222
|
for (const pending of checkpointWaits.values()) pending.done(undefined);
|
|
6008
6223
|
for (const p of permissions.values()) p.done(undefined);
|
|
6009
6224
|
try {
|
|
6010
|
-
await terminalReadsStopped;
|
|
6225
|
+
await Promise.all([terminalReadsStopped, codeFactsStopped]);
|
|
6011
6226
|
await terminalLaunches.settled();
|
|
6012
6227
|
// A peer can still be inside memory recall or its native handshake when kill arrives.
|
|
6013
6228
|
// Drain those starts before taking the final owned-process snapshot.
|
|
@@ -6110,6 +6325,6 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
6110
6325
|
if (piAutoStart() && !recoveryActive() && !piRecovered) autoStartPi();
|
|
6111
6326
|
return { bus, token, port: server.port as number, stop, stopped: new Promise<void>((r) => (onStop = r)) };
|
|
6112
6327
|
} finally {
|
|
6113
|
-
if (!ready) for (const cleanup of startupCleanup.reverse()) { try { cleanup(); } catch { /* preserve startup error */ } }
|
|
6328
|
+
if (!ready) for (const cleanup of startupCleanup.reverse()) { try { await cleanup(); } catch { /* preserve startup error */ } }
|
|
6114
6329
|
}
|
|
6115
6330
|
}
|