@staix/agent-hub 0.12.45 → 0.14.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 +31 -0
- package/README.md +85 -116
- package/docs/agent-notes/README.md +6 -0
- package/docs/agent-notes/adapters.md +10 -7
- package/docs/agent-notes/budget.md +7 -1
- package/docs/agent-notes/bus.md +6 -4
- package/docs/agent-notes/daemon.md +14 -2
- package/docs/agent-notes/local-worker.md +2 -1
- package/docs/agent-notes/tasks.md +16 -3
- package/docs/agent-notes/tests.md +5 -3
- package/docs/dogfood.md +95 -0
- package/docs/events.md +14 -1
- package/docs/operations.md +290 -27
- package/docs/profiles.md +2 -2
- package/docs/quickstart.md +13 -3
- package/docs/sdlc.md +59 -8
- package/docs/security.md +26 -13
- package/docs/smoke.md +9 -4
- package/docs/specs/2026-09-19-agent-hub-design.md +797 -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 +61 -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/2026-10-23-0.14.0.md +69 -0
- package/docs/verification/dogfood/2026-10-22.md +3109 -0
- package/docs/verified.json +22 -17
- package/package.json +1 -1
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +702 -347
- 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 +29 -11
- package/src/adapters/compat.ts +140 -0
- package/src/adapters/definitions/builtin-metadata.ts +3 -2
- package/src/adapters/definitions/native-cli-doctor.ts +12 -5
- package/src/adapters/definitions/native-cli-entry-codex.ts +13 -0
- 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/adapters/reviewer-print.ts +34 -7
- 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 +17 -6
- package/src/cli/identity.ts +29 -6
- package/src/cli/init.ts +109 -11
- package/src/cli/main.ts +127 -11
- package/src/cli/output.ts +91 -3
- package/src/cli/preview.ts +1 -1
- package/src/cli/sdlc-release-guard.ts +82 -0
- package/src/cli/status-lines.ts +1 -1
- package/src/hub/action-class.ts +362 -0
- package/src/hub/action-policy.ts +106 -0
- package/src/hub/board.ts +65 -9
- 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 +319 -34
- 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 +26 -3
- package/src/hub/inbox-polling.ts +25 -0
- package/src/hub/instances.ts +87 -5
- package/src/hub/manager.ts +2 -2
- 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-metrics.ts +260 -0
- package/src/hub/sdlc-queue.ts +31 -9
- package/src/hub/sdlc-release-authorization.ts +173 -0
- package/src/hub/sdlc.ts +54 -14
- package/src/hub/settings.ts +5 -2
- package/src/hub/snapshots.ts +93 -8
- package/src/hub/spec-read.ts +31 -0
- package/src/hub/symbol-normalizer.ts +26 -0
- package/src/hub/task-code-group.ts +52 -0
- package/src/hub/task-code-symbols.ts +90 -0
- package/src/hub/task-sweep.ts +3 -2
- package/src/hub/tasks.ts +455 -67
- package/src/hub/test-integrity.ts +162 -0
- package/src/hub/ui.ts +3 -0
- package/src/hub/usage-rollup.ts +419 -0
- package/src/local/sandbox.ts +5 -3
- package/src/ui/index.html +274 -2
- 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";
|
|
@@ -68,7 +75,7 @@ import { profile, proxyEnv, type SandboxNetwork } from "../local/sandbox.ts";
|
|
|
68
75
|
import { DEFAULT_NETWORK_ALLOW, startEgressProxy, type EgressProxy } from "../local/proxy.ts";
|
|
69
76
|
import { runTool, toolResultFailed, TOOL_SCHEMAS, type ToolApproval, type ApprovalObserver, type ToolContext } from "../local/tools.ts";
|
|
70
77
|
import { isLocalPeer, type LocalPeer } from "../adapters/local-worker.ts";
|
|
71
|
-
import { isReviewerPrintPeer, type ReviewerPrintPeer } from "../adapters/reviewer-print.ts";
|
|
78
|
+
import { isReviewerPrintPeer, reviewerUnavailableReason, ReviewerUnavailableError, type ReviewerPrintPeer } from "../adapters/reviewer-print.ts";
|
|
72
79
|
import { createReviewerMcpSession } from "../adapters/reviewer-mcp.ts";
|
|
73
80
|
import { reviewerPoolConfig } from "./reviewer-pool-config.ts";
|
|
74
81
|
import { reviewDiff } from "../adapters/claude-print.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";
|
|
@@ -98,7 +106,8 @@ import { projectSettings, checkSettingValue, ignoredLine, pendingUndo, pendingUn
|
|
|
98
106
|
import { Bus } from "./bus.ts";
|
|
99
107
|
import { attribute, deliveryTask, type TaskAttribution } from "./attribution.ts";
|
|
100
108
|
import { DeliveryJournal } from "./delivery-journal.ts";
|
|
101
|
-
import { startDashboard, type DashboardSession } from "./ui.ts";
|
|
109
|
+
import { startDashboard, dashboardView, type DashboardSession } from "./ui.ts";
|
|
110
|
+
import { codeGroup } from "./task-code-group.ts";
|
|
102
111
|
import { DAEMON_FEATURES, PROTOCOL, stateDirFor } from "./control-client.ts";
|
|
103
112
|
import { MAX_HOP, newEnvelope, parseMarker, replyParent, sanitize, USER, type Envelope, type PeerId, type Priority } from "./envelope.ts";
|
|
104
113
|
import { BasePeer, DEFAULT_WATCHDOG_MS, type PeerAdapter } from "./peers.ts";
|
|
@@ -145,6 +154,7 @@ export interface HubConfig {
|
|
|
145
154
|
jcode_cmd: string[];
|
|
146
155
|
jcode_home: string;
|
|
147
156
|
jcode_provider_profile: string;
|
|
157
|
+
action_policy?: ActionPolicyConfig;
|
|
148
158
|
permission_modes: Record<string, PermissionMode>;
|
|
149
159
|
protect_instruction_files: boolean;
|
|
150
160
|
/** Loader-owned provenance of requested defaults, before machine-local filtering. */
|
|
@@ -218,6 +228,7 @@ const nativeDefaults = nativeConfigurationDefaults({ home: homedir(), env: proce
|
|
|
218
228
|
export const DEFAULT_CONFIG: HubConfig = {
|
|
219
229
|
watchdog_ms: DEFAULT_WATCHDOG_MS,
|
|
220
230
|
...nativeDefaults.commands,
|
|
231
|
+
action_policy: actionPolicyConfig(),
|
|
221
232
|
permission_modes: {},
|
|
222
233
|
protect_instruction_files: false,
|
|
223
234
|
...nativeDefaults.proxy,
|
|
@@ -263,7 +274,7 @@ const PEER_ID = /^[a-z][a-z0-9-]{0,31}$/;
|
|
|
263
274
|
/** The shared project config, then the machine's own file, which overrides it block by block (issue #17). */
|
|
264
275
|
const CONFIG_FILES = INSTANCE_CONFIG_FILES;
|
|
265
276
|
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"];
|
|
277
|
+
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
278
|
|
|
268
279
|
/** Connection-time policy refresh reads role/feed fields only, never launches or machine-local configuration. */
|
|
269
280
|
export function loadConductorPolicy(cwd: string, layers?: readonly ConfigLayer[]): { roles: Record<string, string[]>; conductor: HubConfig["conductor"]; planner?: string; notices?: string[] } {
|
|
@@ -280,9 +291,10 @@ export function loadConductorPolicy(cwd: string, layers?: readonly ConfigLayer[]
|
|
|
280
291
|
if (!file.roles || typeof file.roles !== "object" || Array.isArray(file.roles)) throw new Error("roles must be an object of peer role lists");
|
|
281
292
|
for (const [peer, assigned] of Object.entries(file.roles)) {
|
|
282
293
|
conductorPeer({ [peer]: assigned });
|
|
283
|
-
const
|
|
294
|
+
const definition = definitionOf(peer), ceiling = definition?.routing?.allowedRoles;
|
|
284
295
|
const declared = assigned as string[];
|
|
285
|
-
|
|
296
|
+
// #525 R1: planner needs a kind that can plan (projection.plannerEligible); such an entry is dropped with the ceiling notice, never thrown.
|
|
297
|
+
roles[peer] = (ceiling ? declared.filter(role => ceiling.includes(role)) : declared).filter(role => role !== "planner" || !definition || definition.projection?.plannerEligible === true);
|
|
286
298
|
if (roles[peer]!.length !== declared.length) notices.push(`roles.${peer}: ignored roles outside the adapter ceiling`);
|
|
287
299
|
}
|
|
288
300
|
}
|
|
@@ -358,6 +370,7 @@ export function loadConfig(cwd: string, layers?: readonly ConfigLayer[], routing
|
|
|
358
370
|
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
371
|
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
372
|
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");
|
|
373
|
+
actionPolicyConfig(file.action_policy);
|
|
361
374
|
instructionProtection(file.protect_instruction_files);
|
|
362
375
|
permissionDefaults(file.permission_modes, instances, adapterCatalog); // malformed declarations fail even when trust would drop them
|
|
363
376
|
peerStartConfig(file.peers, instances, adapterCatalog);
|
|
@@ -421,6 +434,7 @@ export function loadConfig(cwd: string, layers?: readonly ConfigLayer[], routing
|
|
|
421
434
|
...(file.text_projection !== undefined ? { text_projection: projectionConfig(file.text_projection) } : {}),
|
|
422
435
|
...(file.pii !== undefined ? { pii: { paths: policyPaths(file.pii.paths, "pii.paths") } } : {}),
|
|
423
436
|
...(file.data !== undefined ? { data: { readonly: policyPaths(file.data.readonly, "data.readonly") } } : {}),
|
|
437
|
+
action_policy: actionPolicyConfig(file.action_policy),
|
|
424
438
|
permission_modes: { ...instancePermissions, ...permissionDefaults(file.permission_modes, instances, adapterCatalog) },
|
|
425
439
|
protect_instruction_files: instructionProtection(file.protect_instruction_files),
|
|
426
440
|
permission_default_sources: appliedPermissionSources,
|
|
@@ -486,6 +500,8 @@ export interface DaemonOptions {
|
|
|
486
500
|
checkoutBranch?: typeof mainCheckoutBranch;
|
|
487
501
|
/** Host-only admission transport; never selected by project config or tools. */
|
|
488
502
|
releaseHostAdmission?: (cwd: string) => Promise<ReleaseHostAdmission>;
|
|
503
|
+
/** Host-only evidence seam; never supplied by project config or control messages. */
|
|
504
|
+
releaseDocEvidence?: (root: string) => Promise<DocEvidence | undefined>;
|
|
489
505
|
projectId?: string;
|
|
490
506
|
instanceId?: string;
|
|
491
507
|
cwd: string;
|
|
@@ -507,6 +523,10 @@ export interface DaemonOptions {
|
|
|
507
523
|
orphanWatchMs?: number;
|
|
508
524
|
/** Deterministic task-sweep time; the production default is Date.now. */
|
|
509
525
|
taskSweepNow?: () => number;
|
|
526
|
+
/** Test seam: the period of the SDLC band evaluation, default five minutes. */
|
|
527
|
+
bandsEveryMs?: number;
|
|
528
|
+
/** Test seam: an extra hold on the SDLC band evaluation, like a storage error. */
|
|
529
|
+
bandsHeld?: () => boolean;
|
|
510
530
|
/** How long a research record waits after an approval for the task's open turns to end (#247). Tests shrink this. */
|
|
511
531
|
researchGraceMs?: number;
|
|
512
532
|
/** Tests observe timer-driven sync without opening a remote; production uses the package sync. */
|
|
@@ -532,10 +552,29 @@ interface Client {
|
|
|
532
552
|
type Sock = ServerWebSocket<Client>;
|
|
533
553
|
|
|
534
554
|
|
|
555
|
+
/**
|
|
556
|
+
* #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
|
|
557
|
+
* free text from an error (an id or a body would leak). An error that is none of them keeps the old generic sentence.
|
|
558
|
+
*/
|
|
559
|
+
const RESOLUTION_CAUSES: [RegExp, string][] = [
|
|
560
|
+
[/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"],
|
|
561
|
+
[/peer busy/i, "peer busy: the peer is working, and only a queued delivery can be discarded meanwhile"],
|
|
562
|
+
[/stale delivery revision/i, "stale revision: the delivery changed since you read it; run ahub queue show again"],
|
|
563
|
+
[/unknown delivery|delivery not found/i, "unknown delivery: no such delivery id"],
|
|
564
|
+
[/already terminal|already resolved/i, "already resolved: the delivery is settled"],
|
|
565
|
+
[/already scheduled/i, "queued delivery is already scheduled: discard it, or wait for its turn"],
|
|
566
|
+
[/queue is full/i, "recipient queue is full: resolve or discard some of its queued work first"],
|
|
567
|
+
[/journal unavailable/i, "delivery journal unavailable: the hub cannot record a resolution right now"],
|
|
568
|
+
];
|
|
569
|
+
export function resolutionRefusal(error: unknown): string {
|
|
570
|
+
const text = error instanceof Error ? error.message : String(error);
|
|
571
|
+
return RESOLUTION_CAUSES.find(([pattern]) => pattern.test(text))?.[1] ?? "resolution refused: inspect ahub queue show for the delivery's state";
|
|
572
|
+
}
|
|
573
|
+
|
|
535
574
|
export async function startDaemon(opts: DaemonOptions) {
|
|
536
575
|
const projectId = opts.projectId ?? randomUUID();
|
|
537
576
|
const instanceId = opts.instanceId ?? randomUUID();
|
|
538
|
-
const startupCleanup: (() => void)[] = [];
|
|
577
|
+
const startupCleanup: (() => void | Promise<void>)[] = [];
|
|
539
578
|
let ready = false;
|
|
540
579
|
try {
|
|
541
580
|
let startupLayers: ReturnType<typeof readInstanceConfigLayers>;
|
|
@@ -555,6 +594,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
555
594
|
const adapterCatalog = createProjectAdapterCatalog(loadedConfig.adapters, externalDefinitions);
|
|
556
595
|
const configuredIds = adapterCatalog.definitions.filter(d => !builtinDefinitionOf(d.kind)).map(d => d.kind);
|
|
557
596
|
const declarations = validateInstances(loadedConfig.instances, adapterCatalog);
|
|
597
|
+
checkJcodeInstanceHomes(declarations, loadedConfig.jcode_home); // #685: one native home per Jcode, at startup and never partially
|
|
558
598
|
const extraIds = Object.keys(declarations);
|
|
559
599
|
// #450 I3: channel extras (Claude) attach from the person's terminal; the hub never starts them and their hello is admitted.
|
|
560
600
|
const channelExtras = extraIds.filter(id => adapterCatalog.definitionOf(id, declarations)?.attach?.externalHello === true);
|
|
@@ -914,6 +954,26 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
914
954
|
if (refused) log(`limits: ${env.from}: ${refused}`);
|
|
915
955
|
return refused;
|
|
916
956
|
};
|
|
957
|
+
/**
|
|
958
|
+
* #685: the provider, model and profile names an extra Jcode instance was declared with, for status, doctor and the dashboard,
|
|
959
|
+
* exactly as the person wrote them (they authenticate inside Jcode). Never a URL, a token or the native home path.
|
|
960
|
+
*/
|
|
961
|
+
const jcodeModelSource = (id: string): { modelSource?: string } => {
|
|
962
|
+
const instance = declarations[id];
|
|
963
|
+
if (!instance || definitionOf(id)?.instances?.nativeHome !== true) return {};
|
|
964
|
+
return { modelSource: [instance.provider, instance.model, instance.provider_profile && `profile ${instance.provider_profile}`].filter(Boolean).join(" ") || "native default" };
|
|
965
|
+
};
|
|
966
|
+
/**
|
|
967
|
+
* #685 scope update 2: the read-only start preflight for an extra Jcode instance. The declared home must exist as a directory the
|
|
968
|
+
* person owns (the hub never creates it, and reads or writes nothing inside it). The reason names the instance, never the path.
|
|
969
|
+
*/
|
|
970
|
+
const jcodeHomeRefusal = (id: string, home: string): string | undefined => {
|
|
971
|
+
let info: ReturnType<typeof statSync> | undefined;
|
|
972
|
+
try { info = statSync(home); } catch { /* reported below */ }
|
|
973
|
+
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`;
|
|
974
|
+
if (typeof process.getuid === "function" && info.uid !== process.getuid()) return `${id}: its jcode_home is not owned by the person running the hub`;
|
|
975
|
+
return undefined;
|
|
976
|
+
};
|
|
917
977
|
/** This hub run's start: offline owners count from it (issue #6), split predictions read tasks since (issue #109). */
|
|
918
978
|
const hubStartedAt = Date.now();
|
|
919
979
|
// The bus exists before `Tasks`, which knows whether a queued notice still matters (issue #106).
|
|
@@ -1119,6 +1179,8 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
1119
1179
|
let modelRelay: ModelRelay | undefined;
|
|
1120
1180
|
const piReceiptLedgers = new Map<string, PiToolReceipts>();
|
|
1121
1181
|
const modelRelays = new Map<string, ModelRelay>();
|
|
1182
|
+
/** #368: the owned Jcode launch of each Jcode peer, from creation (its MCP child connects while `start()` runs, before the bus has the peer). */
|
|
1183
|
+
const jcodeLaunches = new Map<string, JcodePeer>();
|
|
1122
1184
|
const globalModelRelay = (relay: ModelRelay) => { modelRelay = relay; };
|
|
1123
1185
|
const nativeStateDir = (peer: string) => declarations[peer] ? join(opts.stateDir, "instances", peer) : opts.stateDir;
|
|
1124
1186
|
const instanceAppPorts = new Map<string, number>();
|
|
@@ -1158,8 +1220,12 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
1158
1220
|
event({ type: "conduct", peer: action.actor, action: action.action, ...(action.task === undefined ? {} : { task: action.task }), ...(action.peer ? { target: action.peer } : {}) });
|
|
1159
1221
|
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
1222
|
};
|
|
1223
|
+
const inboxPolling = new InboxPolling();
|
|
1161
1224
|
const board = new Board(join(opts.stateDir, "hub.db"));
|
|
1225
|
+
const codeFacts = new CodeFacts();
|
|
1226
|
+
const reviewSymbols = new Map<string, CodeFactResult<CodeSymbol[]>>();
|
|
1162
1227
|
startupCleanup.push(() => board.close());
|
|
1228
|
+
startupCleanup.push(() => codeFacts.close());
|
|
1163
1229
|
const supervision = new SupervisionFeed({
|
|
1164
1230
|
conductor: () => conductorPeer(config.roles) ?? undefined,
|
|
1165
1231
|
scope: () => config.conductor?.feed ?? "own",
|
|
@@ -1358,9 +1424,19 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
1358
1424
|
projectedReview: task => reviewProjection(task),
|
|
1359
1425
|
ownerWrites: (task, since) => ownerWriteEvidence(task, since),
|
|
1360
1426
|
ownerAccepted: task => captureOwnerWriteStart(task),
|
|
1427
|
+
prepareReviewBrief: async task => {
|
|
1428
|
+
if (tasks.isPii(task) || !task.refs.commit) return;
|
|
1429
|
+
const root = executionRoot(task.owner ?? "");
|
|
1430
|
+
const brief = buildReviewBrief(root, task.refs.commit, { privatePath: path => isDenied(path, config.local.deny) || matchesPolicy(root, path, config.pii?.paths ?? [], true), nameable: tasks.nameable });
|
|
1431
|
+
if ("unavailable" in brief) return;
|
|
1432
|
+
const key = JSON.stringify([root, brief.head, brief.mergeBase]);
|
|
1433
|
+
reviewSymbols.set(key, await codeFacts.changedSymbols(root, brief.mergeBase, brief.head));
|
|
1434
|
+
if (reviewSymbols.size > 64) reviewSymbols.delete(reviewSymbols.keys().next().value!);
|
|
1435
|
+
},
|
|
1361
1436
|
reviewBrief: task => {
|
|
1362
1437
|
const root = executionRoot(task.owner ?? "");
|
|
1363
|
-
|
|
1438
|
+
const brief = buildReviewBrief(root, task.refs.commit, { privatePath: path => isDenied(path, config.local.deny) || matchesPolicy(root, path, config.pii?.paths ?? [], true), nameable: tasks.nameable });
|
|
1439
|
+
return "unavailable" in brief ? brief : withCodeSymbols(brief, reviewSymbols.get(JSON.stringify([root, brief.head, brief.mergeBase])) ?? { state: "unknown" }, task.plan?.symbols, tasks.publicMetadataText);
|
|
1364
1440
|
},
|
|
1365
1441
|
sdlc: { config: () => lifecycleConfig, gh: (args, timeout, _cwd, bound) => sdlcGh(args, timeout, opts.cwd, bound) },
|
|
1366
1442
|
board,
|
|
@@ -1657,6 +1733,10 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
1657
1733
|
/** What quiescence was judged on, for the log when an integration waits on it. */
|
|
1658
1734
|
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
1735
|
board.onChange = (t, h) => {
|
|
1736
|
+
if (!isClosedTaskState(t.state) && t.state !== "backlog") {
|
|
1737
|
+
if (t.owner) inboxPolling.reset(t.owner);
|
|
1738
|
+
if (t.reviewer) inboxPolling.reset(t.reviewer);
|
|
1739
|
+
}
|
|
1660
1740
|
turnFreeNow = undefined;
|
|
1661
1741
|
scopes = new Map();
|
|
1662
1742
|
progress.prune();
|
|
@@ -1757,6 +1837,8 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
1757
1837
|
// Somebody other than the paused peer has to be there, or the handoff would only leave its tasks without an owner.
|
|
1758
1838
|
canHandOff: (peer) => [...bus.peers.keys()].some((id) => accountOf(id) !== accountOf(peer) && ["idle", "busy"].includes(bus.stateOf(id))),
|
|
1759
1839
|
// A reading that arrived through a file carries the file's time; a stale one must not look fresh in the export.
|
|
1840
|
+
// #683 U1: pauses, resumes and derived resets go to the event log, ids and numbers only.
|
|
1841
|
+
ledger: entry => event(entry),
|
|
1760
1842
|
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
1843
|
handoff: (peer, context, urgentOnly) => tasks.reassignForPause(peer, context, urgentOnly),
|
|
1762
1844
|
resumed: (record) => {
|
|
@@ -1783,8 +1865,17 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
1783
1865
|
notify: (line) => { notify(line); feedBudgetPauses(); },
|
|
1784
1866
|
}, accountMembers, accountOf));
|
|
1785
1867
|
budget.setRecoveryHold(recoveryActive());
|
|
1868
|
+
// ponytail: a third full read of events.jsonl at startup (after nativeStopsSeen and claudeNativeUsageSeen). Measured at about 70 ms on the real
|
|
1869
|
+
// 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
|
|
1870
|
+
// 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).
|
|
1871
|
+
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
1872
|
const kimiTokens: { at: number; n: number }[] = [];
|
|
1787
1873
|
startupCleanup.push(() => budget.close());
|
|
1874
|
+
// #683 U2: events.jsonl folded into hub.db, a bounded pass at a time, so `usage` never rescans the log.
|
|
1875
|
+
const usageRollup = new UsageRollup(join(opts.stateDir, "hub.db"), join(opts.stateDir, "events.jsonl"));
|
|
1876
|
+
startupCleanup.push(() => usageRollup.close());
|
|
1877
|
+
const pollUsageRollup = () => { try { usageRollup.poll(); } catch (error) { log(`usage roll-up pass failed: ${(error as Error).message.slice(0, 120)}`); } };
|
|
1878
|
+
pollUsageRollup();
|
|
1788
1879
|
// Turns and their tokens for telemetry (issue #40). Turn ids carry the hub run, so they stay unique across restarts.
|
|
1789
1880
|
const runId = Date.now().toString(36);
|
|
1790
1881
|
let turnSeq = 0;
|
|
@@ -1944,6 +2035,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
1944
2035
|
}
|
|
1945
2036
|
const delta = tokenDeltas();
|
|
1946
2037
|
/** Tokens a peer used, as increments: Kimi reports a session total (turned into increments below), Codex increments. */
|
|
2038
|
+
let codexUsageSeq = 0; // per hub run: with the run id and peer it names one usage record
|
|
1947
2039
|
const addTokens = (peer: PeerId, n: number): number => {
|
|
1948
2040
|
const supervisionTurn = supervisionTurns.get(peer);
|
|
1949
2041
|
if (supervisionTurn && Number.isFinite(n) && n >= 0) supervisionTurn.tokens = (supervisionTurn.tokens ?? 0) + n;
|
|
@@ -1965,6 +2057,8 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
1965
2057
|
};
|
|
1966
2058
|
// Claude's numbers arrive through the status line tee `ahub claude` installs (src/cli/statusline-tee.ts).
|
|
1967
2059
|
let claudeUsageSeen = 0;
|
|
2060
|
+
let bandsRevision = ""; // #481 S7: the board revision and band configuration the last band evaluation that ran saw
|
|
2061
|
+
let bandEvaluations = 0; // evaluations that ran (not skipped for an unchanged board); read by tests through the daemon
|
|
1968
2062
|
const claudeContextSeen = new Map<string, string>();
|
|
1969
2063
|
const contextTailSeen = new Map<PeerId, string>();
|
|
1970
2064
|
const retainedCliAudits = new Set<string>();
|
|
@@ -2022,6 +2116,22 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
2022
2116
|
} catch { /* absent or unreadable native telemetry is unknown */ }
|
|
2023
2117
|
}, 1000),
|
|
2024
2118
|
setInterval(() => budget.tick(), 30_000),
|
|
2119
|
+
setInterval(pollUsageRollup, 10_000),
|
|
2120
|
+
// #481 S7: band tiers are evaluated every five minutes, and only when the board changed since the last evaluation (the status
|
|
2121
|
+
// revision moves on every task write), so an idle board costs one integer read. Held while stopping or recovering, like the task sweep.
|
|
2122
|
+
// ponytail: one evaluation reads every task with its history (a few ms per hundred tasks); bound it by a history window if a board passes several thousand tasks.
|
|
2123
|
+
setInterval(() => {
|
|
2124
|
+
if (stopping || recoveryActive()) return;
|
|
2125
|
+
try {
|
|
2126
|
+
const state = tasks.bandsState();
|
|
2127
|
+
if (!state.ready || opts.bandsHeld?.()) return; // held, mode off or no bands: nothing is consumed, so the breach is still there when it clears
|
|
2128
|
+
const revision = `${board.statusRevision()}:${state.signature}`;
|
|
2129
|
+
if (revision === bandsRevision) return;
|
|
2130
|
+
bandEvaluations++;
|
|
2131
|
+
tasks.proposeFromBands();
|
|
2132
|
+
bandsRevision = `${board.statusRevision()}:${state.signature}`; // after the write: the cards this pass made are not a reason to run again
|
|
2133
|
+
} catch (error) { log(`sdlc band evaluation failed: ${sanitize((error as Error).message).slice(0, 160)}`); }
|
|
2134
|
+
}, opts.bandsEveryMs ?? 300_000),
|
|
2025
2135
|
setInterval(() => {
|
|
2026
2136
|
if (!ready || !memoryBinding) return;
|
|
2027
2137
|
const before = memoryProblem;
|
|
@@ -2147,7 +2257,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
2147
2257
|
}
|
|
2148
2258
|
// Inside a PII turn the worker's words may carry the PII whatever they are attached to: a note would go to
|
|
2149
2259
|
// claude-mem (a cloud observer) and a new task could be routed to a cloud peer without matching any pattern.
|
|
2150
|
-
if (piiTurn && (MEMORY_READ_TOOLS.has(op) || op === "hub_memory_correct" || op === "hub_remember" || op === "hub_task_propose" || op === "hub_sdlc_record" || op === "hub_plan_review" || op === "hub_sdlc_costs" || op.startsWith("hub_release_") || op === "hub_backlog_return_untouched" || op === "hub_backlog_plan" || op === "hub_backlog_undo" || op === "hub_backlog_pull" || op === "hub_backlog_split" || op === "release_new")) throw new PreEffectToolRefusal(`${op} is not available while working on a PII task: its text must not leave this machine`);
|
|
2260
|
+
if (piiTurn && (MEMORY_READ_TOOLS.has(op) || op === "hub_memory_correct" || op === "hub_remember" || op === "hub_task_propose" || op === "hub_sdlc_record" || op === "hub_plan_review" || op === "hub_sdlc_costs" || op === "hub_sdlc_metrics" || op.startsWith("hub_release_") || op === "hub_backlog_return_untouched" || op === "hub_backlog_plan" || op === "hub_backlog_undo" || op === "hub_backlog_pull" || op === "hub_backlog_split" || op === "release_new")) throw new PreEffectToolRefusal(`${op} is not available while working on a PII task: its text must not leave this machine`);
|
|
2151
2261
|
// The same words attached to an ordinary task would reach its reviewer, its owner, overlapping owners (a plan, the
|
|
2152
2262
|
// completed-change notice) and claude-mem. A turn that holds a PII task acts on ordinary tasks in a turn of its own.
|
|
2153
2263
|
if (piiTurn && (op === "hub_task_done" || op === "hub_review" || (op === "hub_task_accept" && (a.plan != null || a.refs != null)))) {
|
|
@@ -2277,6 +2387,8 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
2277
2387
|
return JSON.stringify(await tasks.groomingDigest(by));
|
|
2278
2388
|
case "hub_sdlc_costs":
|
|
2279
2389
|
return JSON.stringify(await tasks.costReport(by, a.repo));
|
|
2390
|
+
case "hub_sdlc_metrics":
|
|
2391
|
+
return JSON.stringify(tasks.sdlcMetrics(by, a.since));
|
|
2280
2392
|
case "hub_plan_review": {
|
|
2281
2393
|
const { id, ...review } = a;
|
|
2282
2394
|
return JSON.stringify(tasks.publicView(await tasks.recordPlanReview(by, id, review)));
|
|
@@ -2297,10 +2409,11 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
2297
2409
|
return JSON.stringify(tasks.publicView(await tasks.undoPlanning(by, a.id, a.revision)));
|
|
2298
2410
|
case "hub_task_propose": {
|
|
2299
2411
|
const t = await tasks.propose(by, a);
|
|
2300
|
-
|
|
2412
|
+
const repoHint = tasks.refsRepoHint(t); // #723: advice only; never part of the write
|
|
2413
|
+
if (t.state === "backlog") return [line(t), repoHint].filter(Boolean).join("\n");
|
|
2301
2414
|
const overlap = tasks.overlaps(t, t.owner === by);
|
|
2302
2415
|
if (t.owner === by && tasks.silentFor(t.id) && factsOn()) factsFor(by).sawPlans(by, tasks.overlapTasks(t)); // as for an accept
|
|
2303
|
-
return [line(t), tasks.namedOwnerNote(t, a.owner as PeerId | undefined, by), overlap, tasks.claimRoleGuidance(t, by), t.owner === by ? tasks.planGuardText(t) : ""].filter(Boolean).join("\n");
|
|
2416
|
+
return [line(t), tasks.namedOwnerNote(t, a.owner as PeerId | undefined, by), overlap, tasks.claimRoleGuidance(t, by), t.owner === by ? tasks.planGuardText(t) : "", repoHint].filter(Boolean).join("\n");
|
|
2304
2417
|
}
|
|
2305
2418
|
case "hub_task_accept": {
|
|
2306
2419
|
// S1 spec acceptance and S9 plan review are checked against one task snapshot before acceptance.
|
|
@@ -2412,7 +2525,10 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
2412
2525
|
}
|
|
2413
2526
|
case "task_show": {
|
|
2414
2527
|
const task = board.get(Number(a.id));
|
|
2415
|
-
|
|
2528
|
+
if (!task) return JSON.stringify(`no task #${a.id}`);
|
|
2529
|
+
const { codeSymbols: _storedCodeSymbols, ...detail } = task;
|
|
2530
|
+
const codeSymbols = tasks.publicView(task).codeSymbols;
|
|
2531
|
+
return JSON.stringify({ ...detail, ...(codeSymbols ? { codeSymbols } : {}), symbolOverlaps: tasks.symbolOverlaps(task), flowRevision: task.history.length, reviews: board.reviews({ task: task.id }) }, null, 2);
|
|
2416
2532
|
}
|
|
2417
2533
|
case "task_assign":
|
|
2418
2534
|
case "task_assign_checked": {
|
|
@@ -2496,6 +2612,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
2496
2612
|
else delete defaultSources[peer];
|
|
2497
2613
|
}
|
|
2498
2614
|
};
|
|
2615
|
+
const actionBudgets = new ActionBudgets(() => config.action_policy, contextSession);
|
|
2499
2616
|
const permissionMode = (peer: string): PermissionMode => permissionModes.get(peer) ?? "ask";
|
|
2500
2617
|
const permissionModeSources = new Map(Object.entries(defaultSources).map(([peer, value]) => [peer, value.source]));
|
|
2501
2618
|
const unattendedPeers = new Set<string>();
|
|
@@ -2504,6 +2621,12 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
2504
2621
|
const claudePermissionGenerations = new Map<string, string>();
|
|
2505
2622
|
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
2623
|
let stopping = false;
|
|
2624
|
+
const releaseAuthorizations = new ReleaseAuthorizations(opts.cwd, {
|
|
2625
|
+
docs: opts.releaseDocEvidence,
|
|
2626
|
+
gh: (args, timeout) => sdlcGh(args, timeout, opts.cwd),
|
|
2627
|
+
current: () => !stopping && !recoveryActive() && !bus.isRecoveryHeld,
|
|
2628
|
+
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 }),
|
|
2629
|
+
});
|
|
2507
2630
|
const terminalReads = terminalOwnerReads();
|
|
2508
2631
|
const terminalLaunches = new TerminalLaunches({
|
|
2509
2632
|
owner: row => {
|
|
@@ -2788,10 +2911,11 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
2788
2911
|
...(dashboard ? { uiOrigin: dashboard.origin } : {}),
|
|
2789
2912
|
codexProxyPort: opts.codexProxyPort,
|
|
2790
2913
|
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 ?? [] } : {}) }]; }),
|
|
2914
|
+
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), ...(reviewerUnavailable.has(id) ? { reviewerUnavailable: reviewerUnavailable.get(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
2915
|
),
|
|
2793
2916
|
taskWip: Object.fromEntries(peerIds.map(id => [id, { wip: tasks.wip(id), wipLimit: tasks.wipLimit(id) }])),
|
|
2794
2917
|
boardReadCapabilities: { releaseFilter: true, columnMetadata: true },
|
|
2918
|
+
actionPolicyInfo: Object.fromEntries(peerIds.map(id => [id, actionBudgets.status(id, false)])),
|
|
2795
2919
|
roleInfo: Object.fromEntries(peerIds.map(id => [id, { flowRoles: config.roles[id] ?? [], catalogRoles: roleState(id), ...(config.roles[id]?.includes("planner") ? { planner: plannerSource } : {}) }])),
|
|
2796
2920
|
planner: { peers: plannerPeers(), source: plannerSource },
|
|
2797
2921
|
...(pendingPermissionDefaults.size ? { permissionDefaults: [...pendingPermissionDefaults].map(([peer, source]) => ({ peer, mode: "never-ask", source })) } : {}),
|
|
@@ -3706,12 +3830,24 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
3706
3830
|
}
|
|
3707
3831
|
if (adapter?.start.daemonPort === "owned-acp") {
|
|
3708
3832
|
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
|
-
|
|
3833
|
+
// #691: name a version refusal before the owned daemon starts; the ACP identity check stays the binding one.
|
|
3834
|
+
const jcodeVerdict = await jcodeVersionAsync(config.jcode_cmd, executionCwd);
|
|
3835
|
+
if (!jcodeVerdict.ok) return { ok: false, error: jcodeVerdict.reason };
|
|
3836
|
+
if (jcodeVerdict.note) log(jcodeVerdict.note);
|
|
3837
|
+
// #685: an extra instance runs its own native home and model fields; the base peer keeps jcode_home and the global provider profile.
|
|
3838
|
+
const instance = declarations[peer];
|
|
3839
|
+
// #685 scope update 2: the person authenticates inside Jcode against that home. The hub passes -p, -m and --provider-profile as
|
|
3840
|
+
// written and reads or writes no credential; the only preflight is that the declared home is a directory the person owns.
|
|
3841
|
+
if (instance?.jcode_home) { const refusal = jcodeHomeRefusal(peer, instance.jcode_home); if (refusal) return { ok: false, error: refusal }; }
|
|
3842
|
+
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,
|
|
3843
|
+
providerProfile: (args.providerProfile ?? instance?.provider_profile ?? config.jcode_provider_profile) || undefined,
|
|
3844
|
+
...(instance?.provider ? { provider: instance.provider } : {}),
|
|
3845
|
+
...(args.model ?? instance?.model ? { launchModel: (args.model ?? instance?.model)! } : {}), ...(args.sessionId ? { resumeSessionId: args.sessionId } : {}), cwd: executionCwd, watchdogMs: config.watchdog_ms,
|
|
3711
3846
|
preamble: sessionRoleContract(peer), log: () => log("Jcode native diagnostic: details withheld") }) }) as JcodePeer;
|
|
3847
|
+
jcodeLaunches.set(peer, jcode);
|
|
3712
3848
|
recoveryTaskPreface(peer); await ensurePreface(peer);
|
|
3713
3849
|
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)"); }
|
|
3850
|
+
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
3851
|
return { ok: true, policy: "unmanaged" };
|
|
3716
3852
|
}
|
|
3717
3853
|
if (adapter?.start.acpCommand) {
|
|
@@ -3796,7 +3932,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
3796
3932
|
if (configuredIds.includes(adapter.kind) && owner.pendingOwnedProcess) bus.add(owner); // Retain the failed owner for explicit stop retry; never replace its live child.
|
|
3797
3933
|
if (configuredIds.includes(adapter.kind)) throw new Error(`${peer}: ACP startup identity, session, permission or owned-group verification failed (details withheld)`);
|
|
3798
3934
|
if (error instanceof QwenIdentityError) throw error;
|
|
3799
|
-
throw new Error(
|
|
3935
|
+
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
3936
|
}
|
|
3801
3937
|
return { ok: true };
|
|
3802
3938
|
}
|
|
@@ -3807,6 +3943,12 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
3807
3943
|
admitAttach: () => membershipAllowed(peer),
|
|
3808
3944
|
permissionMode: permissionMode(peer),
|
|
3809
3945
|
onTokens: (added) => void addTokens(peer, added),
|
|
3946
|
+
// #683 U1: the same update split into Codex's own kinds, next to the unchanged `tokens` event. One record per update:
|
|
3947
|
+
// 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).
|
|
3948
|
+
onTokenBreakdown: delta => {
|
|
3949
|
+
event({ type: "usage", peer, source: "codex_appserver", id: `codex:${runId}:${peer}:${++codexUsageSeq}`, inputTokens: delta.input, cacheReadTokens: delta.cachedInput,
|
|
3950
|
+
outputTokens: delta.output, reasoningTokens: delta.reasoning, totalTokens: delta.total, ...taskAttribution(peer) });
|
|
3951
|
+
},
|
|
3810
3952
|
contextReader: adapter.budget?.context,
|
|
3811
3953
|
onContext: adapter.budget?.context ? (reading) => contexts.report(peer, reading, contextSession(peer)) : undefined,
|
|
3812
3954
|
// #215: before the thread's first turn_start, so the planner can tell which turns belong to which thread.
|
|
@@ -4146,7 +4288,12 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
4146
4288
|
onUnavailable: (failure, current) => {
|
|
4147
4289
|
const attached = () => current() && bus.peers.get(peer) === reviewer;
|
|
4148
4290
|
if (!attached()) return;
|
|
4149
|
-
if (failure.cause === "start_failed")
|
|
4291
|
+
if (failure.cause === "start_failed") {
|
|
4292
|
+
// #722: the class (a closed list) is the only detail; never a path, native text or token.
|
|
4293
|
+
const reason = reviewerUnavailableReason(failure.causeClass ?? "unknown", failure.causeDetail);
|
|
4294
|
+
log(`reviewer ${peer} unavailable for task #${failure.task}: ${sanitize(reason).slice(0, 300)}`);
|
|
4295
|
+
reviewerUnavailable.set(peer, sanitize(reason).slice(0, 1000));
|
|
4296
|
+
}
|
|
4150
4297
|
return tasks.retryReviewer(failure.task, peer, failure, attached).then(() => undefined);
|
|
4151
4298
|
},
|
|
4152
4299
|
log,
|
|
@@ -4154,8 +4301,10 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
4154
4301
|
try { await reviewer.start(); }
|
|
4155
4302
|
catch (error) {
|
|
4156
4303
|
await reviewer.stop().catch(() => {});
|
|
4157
|
-
|
|
4158
|
-
|
|
4304
|
+
// #691: a version refusal names only versions and the trust command, so it is shown for every backend.
|
|
4305
|
+
const reason = error instanceof Error && (backend.nativePolicy || error.message.startsWith(VERSION_REFUSAL)) ? sanitize(error.message).slice(0, 1000)
|
|
4306
|
+
: reviewerUnavailableReason(error instanceof ReviewerUnavailableError ? error.causeClass : "unknown", error instanceof ReviewerUnavailableError ? error.detail : undefined);
|
|
4307
|
+
log(`start ${peer} failed [cause: ${error instanceof ReviewerUnavailableError ? error.causeClass : "unknown"}]`);
|
|
4159
4308
|
reviewerUnavailable.set(peer, reason);
|
|
4160
4309
|
return { ok: false, error: reason };
|
|
4161
4310
|
}
|
|
@@ -4315,7 +4464,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
4315
4464
|
const text = (v: unknown, max = 200) => typeof v === "string" && v.trim() && v.length <= max ? v.trim() : undefined;
|
|
4316
4465
|
const tier = q.tier === "short" || q.tier === "long" || q.tier === "goals" ? q.tier : "long";
|
|
4317
4466
|
const kind = typeof q.kind === "string" && (["decision", "finding", "contract", "fail"] as string[]).includes(q.kind) ? q.kind as MemoryView["kind"] : undefined;
|
|
4318
|
-
const peer = text(q.peer, 32), machine = text(q.machine, 32), search = text(q.q), node = text(q.node), since = [text(q.since, 40)].find((v) => v !== undefined && !Number.isNaN(Date.parse(v))); // an unreadable since filters nothing
|
|
4467
|
+
const peer = text(q.peer, 32), machine = text(q.machine, 32), search = text(q.q), node = text(q.node, typeof q.node === "string" && q.node.startsWith("symbol:") ? 512 : 200), since = [text(q.since, 40)].find((v) => v !== undefined && !Number.isNaN(Date.parse(v))); // an unreadable since filters nothing
|
|
4319
4468
|
const hops = q.hops === 2 ? 2 : 1;
|
|
4320
4469
|
const all = memoryStore.list({ project: memoryProject, limit: Number.MAX_SAFE_INTEGER });
|
|
4321
4470
|
const goals = memoryStore.goals(memoryProject);
|
|
@@ -4353,6 +4502,43 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
4353
4502
|
const attentionStatus = status(false, true);
|
|
4354
4503
|
let pet; try { const config = loadPetConfig(opts.cwd); pet = { enabled: config.enabled, character: config.character ?? "stai", blink: config.blink }; } catch { pet = { enabled: false }; }
|
|
4355
4504
|
const peerView = Object.fromEntries(Object.entries(attentionStatus.peers).map(([id, peer]) => [id, { ...peer, ...turnView(id as PeerId) }])), boardTasks = board.list();
|
|
4505
|
+
const usageView = (open: boolean) => {
|
|
4506
|
+
// #683 U4: the roll-up runs only while the Agents view that shows it is open, never on a hidden view's 1 s poll.
|
|
4507
|
+
if (!open) return undefined;
|
|
4508
|
+
try {
|
|
4509
|
+
const caught = usageRollup.catchUp(100);
|
|
4510
|
+
const status = budget.status();
|
|
4511
|
+
const live = new Set(Object.entries(status).filter(([, v]) => v.paused).map(([peer]) => peer));
|
|
4512
|
+
const limits = usageRollup.limits(undefined, { limit: 100, live });
|
|
4513
|
+
const total = usageRollup.total();
|
|
4514
|
+
const daily = usageRollup.daily(14);
|
|
4515
|
+
const taskUsage = usageRollup.query({ by: "task" });
|
|
4516
|
+
return {
|
|
4517
|
+
// `behind`: the roll-up has not folded the whole log, so every figure below is a lower bound, never the whole (unknown is not 0).
|
|
4518
|
+
behind: !caught,
|
|
4519
|
+
daily,
|
|
4520
|
+
limits,
|
|
4521
|
+
total,
|
|
4522
|
+
tasks: taskUsage.rows.map(r => ({
|
|
4523
|
+
id: r.key === "unattributed" ? 0 : Number(r.key.replace(/^#/, "")),
|
|
4524
|
+
pii: r.pii === true,
|
|
4525
|
+
records: r.records,
|
|
4526
|
+
input: r.input,
|
|
4527
|
+
output: r.output,
|
|
4528
|
+
cacheRead: r.cacheRead,
|
|
4529
|
+
cacheWrite: r.cacheWrite,
|
|
4530
|
+
reasoning: r.reasoning,
|
|
4531
|
+
total: r.total,
|
|
4532
|
+
counted: r.counted,
|
|
4533
|
+
totalWithoutCache: r.totalWithoutCache,
|
|
4534
|
+
})),
|
|
4535
|
+
};
|
|
4536
|
+
} catch (e) {
|
|
4537
|
+
log(`dashboard usage view failed: ${(e as Error).message}`);
|
|
4538
|
+
// The page keeps the last figures for `undefined` (a hidden view), so a failed read says so explicitly instead.
|
|
4539
|
+
return { unavailable: true };
|
|
4540
|
+
}
|
|
4541
|
+
};
|
|
4356
4542
|
return {
|
|
4357
4543
|
ok: true,
|
|
4358
4544
|
projectId,
|
|
@@ -4372,7 +4558,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
4372
4558
|
tasks: boardTasks.map((task) => {
|
|
4373
4559
|
const view = tasks.publicView(task);
|
|
4374
4560
|
// 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 } : {}) };
|
|
4561
|
+
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 } : {}), ...(() => { const group = codeGroup(task, view.codeSymbols, task.plan?.symbols, tasks.publicMetadataText, tasks.isPii(task)); return group ? { codeGroup: group } : {}; })(), ...(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
4562
|
}),
|
|
4377
4563
|
links: links.filter(link => link.tasks.every(id => { const task = board.get(id); return task && !tasks.isPii(task); })),
|
|
4378
4564
|
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))) }])),
|
|
@@ -4391,6 +4577,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
4391
4577
|
roster: rosterView(),
|
|
4392
4578
|
...benchView(),
|
|
4393
4579
|
...memoryView(_input?.memory),
|
|
4580
|
+
usage: usageView(_input?.view === "agents"),
|
|
4394
4581
|
};
|
|
4395
4582
|
}
|
|
4396
4583
|
|
|
@@ -4487,7 +4674,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
4487
4674
|
settledHolderChange = JSON.stringify(oldHolders) !== JSON.stringify(newHolders);
|
|
4488
4675
|
if (settledHolderChange) effect = `planner ${JSON.stringify(oldHolders.planners)} -> ${JSON.stringify(newHolders.planners)}; conductor ${JSON.stringify(oldHolders.conductors)} -> ${JSON.stringify(newHolders.conductors)}`;
|
|
4489
4676
|
}
|
|
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) };
|
|
4677
|
+
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
4678
|
const decision = authorizeConfigLeaf(input);
|
|
4492
4679
|
if (!decision.allowed) {
|
|
4493
4680
|
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 +4790,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
4603
4790
|
if (change.applies === "restart" || change.key === "roles" || change.key.startsWith("roles.")) continue;
|
|
4604
4791
|
putConfig(config, change.key, change.after);
|
|
4605
4792
|
}
|
|
4793
|
+
if (changes.some(change => change.key === "action_policy" || change.key.startsWith("action_policy."))) actionBudgets.policyChanged();
|
|
4606
4794
|
if (rolesChanged) { policyRoles = structuredClone(prepared.running.roles); settlePlanner(); }
|
|
4607
4795
|
for (const change of changes.filter(change => change.key === "permission_modes" || change.key.startsWith("permission_modes."))) {
|
|
4608
4796
|
for (const peer of change.key === "permission_modes" ? SUPPORTED_PEERS : [change.key.split(".")[1]!]) changedPermissionDefaults.add(peer);
|
|
@@ -4936,7 +5124,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
4936
5124
|
if (a.action === "sdlc_queue" || a.action === "sdlc_release") {
|
|
4937
5125
|
const liveIssues = a.liveIssues ?? [];
|
|
4938
5126
|
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);
|
|
5127
|
+
const report = a.action === "sdlc_queue" ? await tasks.deliveryQueue(a.repo) : releaseAuthorizations.report(await tasks.releaseReadiness(a.repo, liveIssues));
|
|
4940
5128
|
return { ok: true, report };
|
|
4941
5129
|
}
|
|
4942
5130
|
switch (a.action) {
|
|
@@ -5313,6 +5501,12 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
5313
5501
|
c.peer = String(msg.peer ?? "");
|
|
5314
5502
|
if (!PEER_ID.test(c.peer) || c.peer === USER || c.peer === "hub") return sock.close(4403, "peer id is reserved or malformed");
|
|
5315
5503
|
if (!membershipAllowed(c.peer)) return sock.close(4000, "peer is off roster; retry when a person adds it");
|
|
5504
|
+
// #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
|
|
5505
|
+
// 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.
|
|
5506
|
+
if (definitionOf(c.peer)?.start.daemonPort === "owned-acp") {
|
|
5507
|
+
const owner = jcodeLaunches.get(c.peer);
|
|
5508
|
+
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");
|
|
5509
|
+
}
|
|
5316
5510
|
} else {
|
|
5317
5511
|
c.peer = String(msg.peer ?? defaultChannelPeer(undefined));
|
|
5318
5512
|
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 +5570,31 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
5376
5570
|
if (msg.peer !== undefined && (typeof msg.peer !== "string" || !PEER_ID.test(msg.peer))) return void reply({ ok: false, error: "invalid peer" });
|
|
5377
5571
|
return void reply({ ok: true, deliveries: bus.queueList(msg.peer).map((row) => queueView(row)) });
|
|
5378
5572
|
}
|
|
5573
|
+
if (msg.op === "discard") {
|
|
5574
|
+
// #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
|
|
5575
|
+
// 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
|
|
5576
|
+
// because only the named peer is read. Refused while recovery or shutdown holds queue mutations, like every queue resolution.
|
|
5577
|
+
if (typeof msg.peer !== "string" || !PEER_ID.test(msg.peer)) return void reply({ ok: false, error: "peer must be a peer id" });
|
|
5578
|
+
const state = msg.state === undefined ? "queued" : msg.state;
|
|
5579
|
+
if (state !== "queued" && state !== "needs_review") return void reply({ ok: false, error: "state is queued or needs_review" });
|
|
5580
|
+
const rows = bus.queueList(msg.peer).filter((row) => row.state === state);
|
|
5581
|
+
if (msg.confirmed !== true) return void reply({ ok: true, op: "discard", peer: msg.peer, state, count: rows.length, discarded: 0, refused: 0 });
|
|
5582
|
+
if (typeof msg.reason !== "string" || !msg.reason.trim() || msg.reason.length > 2000) return void reply({ ok: false, error: "discard needs a reason" });
|
|
5583
|
+
if (recoveryActive() || stopping) return void reply({ ok: false, error: "recovery or shutdown is holding queue mutations" });
|
|
5584
|
+
let discarded = 0; const refused: Record<string, number> = {};
|
|
5585
|
+
for (const row of rows) {
|
|
5586
|
+
// 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
|
|
5587
|
+
// 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.
|
|
5588
|
+
const current = bus.queueShow(row.id) ?? bus.queueList(msg.peer).find((r) => r.id === row.id);
|
|
5589
|
+
try {
|
|
5590
|
+
if (!current || current.state !== state) throw new Error("unknown delivery");
|
|
5591
|
+
bus.resolveDelivery(row.id, current.revision, "discard", msg.reason.trim()); discarded++;
|
|
5592
|
+
}
|
|
5593
|
+
catch (error) { const why = resolutionRefusal(error).split(":")[0]!; refused[why] = (refused[why] ?? 0) + 1; }
|
|
5594
|
+
}
|
|
5595
|
+
writeStatus();
|
|
5596
|
+
return void reply({ ok: true, op: "discard", peer: msg.peer, state, count: rows.length, discarded, refused: rows.length - discarded, ...(rows.length - discarded ? { refusedBy: refused } : {}) });
|
|
5597
|
+
}
|
|
5379
5598
|
if (typeof msg.id !== "string" || !msg.id || msg.id.length > 256) return void reply({ ok: false, error: "invalid delivery ID" });
|
|
5380
5599
|
if (msg.op === "show") {
|
|
5381
5600
|
const row = bus.queueShow(msg.id);
|
|
@@ -5387,7 +5606,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
5387
5606
|
bus.resolveDelivery(msg.id, msg.revision, msg.action, msg.reason.trim());
|
|
5388
5607
|
writeStatus();
|
|
5389
5608
|
return void reply({ ok: true, delivery: queueView(bus.queueShow(msg.id)) });
|
|
5390
|
-
} catch { return void reply({ ok: false, error:
|
|
5609
|
+
} catch (error) { return void reply({ ok: false, error: resolutionRefusal(error) }); }
|
|
5391
5610
|
}
|
|
5392
5611
|
case "delivery_holds": {
|
|
5393
5612
|
if (!c.peer || (c.role !== "peer" && c.role !== "tools")) return void reply({ ok: false, error: "held metadata requires a peer identity" });
|
|
@@ -5413,9 +5632,15 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
5413
5632
|
const envs = bus.pull(peer.id);
|
|
5414
5633
|
if (!envs) return void reply({ ok: false, error: queueHold(peer.id) ?? (bus.isRecoveryHeld ? "recovery is holding deliveries" : pauseReason(peer.id)) });
|
|
5415
5634
|
peer.lastInboxAt = Date.now();
|
|
5635
|
+
let polling: ReturnType<InboxPolling["read"]>;
|
|
5636
|
+
try {
|
|
5637
|
+
const assigned = peer.pullOnly && board.statusTasks().some(task => !isClosedTaskState(task.state) && task.state !== "backlog" && (task.owner === peer.id || task.reviewer === peer.id));
|
|
5638
|
+
polling = peer.pullOnly ? inboxPolling.read(peer.id, envs.length === 0, assigned, peer.lastInboxAt) : undefined;
|
|
5639
|
+
if (polling?.notice) notify(`${peer.id} is polling an empty inbox with no task (${polling.count} empty reads in ${polling.minutes} min)`);
|
|
5640
|
+
} catch { polling = undefined; } // The pull already completed: advice must never discard its envelopes.
|
|
5416
5641
|
writeStatus();
|
|
5417
5642
|
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) });
|
|
5643
|
+
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
5644
|
} catch { return void reply({ ok: false, error: "delivery journal unavailable" }); }
|
|
5420
5645
|
}
|
|
5421
5646
|
case "delivery_complete": {
|
|
@@ -5749,11 +5974,31 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
5749
5974
|
return void reply({ t: "ui", ok: false, error: "could not start the dashboard" });
|
|
5750
5975
|
}
|
|
5751
5976
|
case "sdlc": {
|
|
5977
|
+
if (msg.op === "release-guard" || msg.op === "release-guard-hook") {
|
|
5978
|
+
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" });
|
|
5979
|
+
if (msg.op === "release-guard") {
|
|
5980
|
+
const parsed = parseReleaseGuardArgv(msg.argv);
|
|
5981
|
+
if (!parsed.ok) return void reply({ ok: false, error: parsed.reason });
|
|
5982
|
+
void releaseAuthorizations.guardRequest(parsed.request).then(reply, () => reply({ ok: false, error: "release guard evidence unavailable" }));
|
|
5983
|
+
} else {
|
|
5984
|
+
const parsed = parseReleasePushUpdates(msg.remoteName, msg.remoteUrl, msg.stdin);
|
|
5985
|
+
if (!parsed.ok) return void reply({ ok: false, error: parsed.reason });
|
|
5986
|
+
reply(releaseAuthorizations.guardPush(parsed.remoteName, parsed.remoteUrl, parsed.updates));
|
|
5987
|
+
}
|
|
5988
|
+
return;
|
|
5989
|
+
}
|
|
5752
5990
|
if (c.role !== "console") return void reply({ ok: false, error: "SDLC readback is console only; task owners record ids through hub_sdlc_record" });
|
|
5991
|
+
if (msg.op === "authorize-release") {
|
|
5992
|
+
void releaseAuthorizations.authorize(USER, msg.version, msg.commit).then(
|
|
5993
|
+
authorization => reply({ ok: true, authorization }),
|
|
5994
|
+
error => reply({ ok: false, error: releaseAuthorizationError(error) }),
|
|
5995
|
+
);
|
|
5996
|
+
return;
|
|
5997
|
+
}
|
|
5753
5998
|
if (msg.op === "queue" || msg.op === "release") {
|
|
5754
5999
|
if (typeof msg.repo !== "string" || !Array.isArray(msg.liveIssues ?? []) || (msg.liveIssues ?? []).length > 32) return void reply({ ok: false, error: "invalid delivery readback arguments" });
|
|
5755
6000
|
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" }));
|
|
6001
|
+
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
6002
|
return;
|
|
5758
6003
|
}
|
|
5759
6004
|
if (msg.op === "export") {
|
|
@@ -5788,7 +6033,11 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
5788
6033
|
if (msg.id !== undefined && (!Number.isSafeInteger(msg.id) || msg.id <= 0 || !selected.length)) return void reply({ ok: false, error: "invalid lifecycle task id" });
|
|
5789
6034
|
void (async () => {
|
|
5790
6035
|
const rows = [];
|
|
5791
|
-
for (const task of selected.slice(0, 100))
|
|
6036
|
+
for (const task of selected.slice(0, 100)) {
|
|
6037
|
+
const status = await tasks.lifecycleStatus(task.id, msg.id !== undefined);
|
|
6038
|
+
const current = board.get(task.id) ?? task;
|
|
6039
|
+
rows.push({ id: task.id, title: tasks.publicTitle(current), ...(tasks.isPii(current) ? {} : { receipt: tasks.publicView(current).sdlc }), ...status });
|
|
6040
|
+
}
|
|
5792
6041
|
reply({ ok: true, mode: lifecycleConfig.mode, rows, truncated: selected.length > 100 });
|
|
5793
6042
|
})().catch(() => reply({ ok: false, error: "lifecycle readback unavailable" }));
|
|
5794
6043
|
return;
|
|
@@ -5805,7 +6054,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
5805
6054
|
case "ui_snapshot":
|
|
5806
6055
|
if (c.role !== "console") return void reply({ ok: false, error: "ui_snapshot is a console command" });
|
|
5807
6056
|
if (!Number.isSafeInteger(msg.after ?? 0) || (msg.after ?? 0) < 0) return void reply({ ok: false, error: "invalid cursor" });
|
|
5808
|
-
return void reply(uiSnapshot(msg.after ?? 0, msg.memory !== undefined ? { memory: msg.memory } : undefined));
|
|
6057
|
+
return void reply(uiSnapshot(msg.after ?? 0, msg.memory !== undefined || dashboardView(msg.view) ? { ...(msg.memory !== undefined ? { memory: msg.memory } : {}), ...(dashboardView(msg.view) ? { view: msg.view } : {}) } : undefined));
|
|
5809
6058
|
case "ui_action":
|
|
5810
6059
|
if (c.role !== "console") return void reply({ ok: false, error: "ui_action is a console command" });
|
|
5811
6060
|
if (recoveryActive()) return void reply({ ok: false, error: "recovery is holding mutations" });
|
|
@@ -5964,6 +6213,41 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
5964
6213
|
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
6214
|
}
|
|
5966
6215
|
return void reply({ t: "budget", ok: true, budget: budget.status(), gate: config.budget.gate });
|
|
6216
|
+
case "usage": {
|
|
6217
|
+
// #683 U2: the console, the person and a configured planner read usage; every other agent is refused. Always answered (rid).
|
|
6218
|
+
const planner = c.role === "tools" && !!c.peer && !!config.roles[c.peer]?.includes("planner");
|
|
6219
|
+
if (c.role !== "console" && !planner) return void reply({ t: "usage", ok: false, error: "usage is read by the person and a configured planner" });
|
|
6220
|
+
if (planner && c.peer && turns.get(c.peer)?.private) return void reply({ t: "usage", ok: false, error: "usage is unavailable during a private turn" });
|
|
6221
|
+
const op = msg.op === undefined ? "query" : msg.op;
|
|
6222
|
+
const num = (v: unknown): number | undefined | null => v === undefined ? undefined : typeof v === "number" && Number.isFinite(v) && v >= 0 ? v : null;
|
|
6223
|
+
const since = num(msg.since), until = num(msg.until);
|
|
6224
|
+
if (since === null || until === null) return void reply({ t: "usage", ok: false, error: "since and until are epoch milliseconds" });
|
|
6225
|
+
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" });
|
|
6226
|
+
try {
|
|
6227
|
+
// 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
|
|
6228
|
+
// 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).
|
|
6229
|
+
// 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
|
|
6230
|
+
// 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.
|
|
6231
|
+
const behind = !usageRollup.catchUp(250);
|
|
6232
|
+
const reply_ = (body: Record<string, unknown>) => reply({ ...body, ...(behind ? { behind: true } : {}) });
|
|
6233
|
+
if (op === "limits") {
|
|
6234
|
+
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" });
|
|
6235
|
+
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}` });
|
|
6236
|
+
const status = budget.status();
|
|
6237
|
+
// 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.
|
|
6238
|
+
const live = new Set(Object.entries(status).filter(([, v]) => v.paused).map(([peer]) => peer));
|
|
6239
|
+
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)) });
|
|
6240
|
+
}
|
|
6241
|
+
if (op === "total") return void reply_({ t: "usage", ok: true, op, total: usageRollup.total() });
|
|
6242
|
+
if (op !== "query") return void reply({ t: "usage", ok: false, error: "op is query, limits or total" });
|
|
6243
|
+
const by = (msg.by ?? "peer") as UsageGroup;
|
|
6244
|
+
if (!USAGE_GROUPS.includes(by)) return void reply({ t: "usage", ok: false, error: `by is one of ${USAGE_GROUPS.join(", ")}` });
|
|
6245
|
+
// issue, pr and release are the board's current links of each task (ids only, never a title).
|
|
6246
|
+
const board_ = new Map(board.list().map(t => [t.id, t]));
|
|
6247
|
+
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; };
|
|
6248
|
+
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) });
|
|
6249
|
+
} catch (error) { return void reply({ t: "usage", ok: false, error: `usage unavailable: ${(error as Error).message.slice(0, 120)}` }); }
|
|
6250
|
+
}
|
|
5967
6251
|
case "permit":
|
|
5968
6252
|
if (c.role !== "console") return void reply({ t: "permit", ok: false, error: "permit is a console command" });
|
|
5969
6253
|
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 +6276,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
5992
6276
|
stopping = true;
|
|
5993
6277
|
terminalLaunches.clear();
|
|
5994
6278
|
// Start cancellation immediately; a failed read cleanup must not strand the other owned peers or finalization.
|
|
6279
|
+
const codeFactsStopped = codeFacts.close().catch(() => { log("code facts cleanup failed during shutdown"); });
|
|
5995
6280
|
const terminalReadsStopped = terminalReads.stop().catch(() => { log("terminal owner query cleanup failed during shutdown"); });
|
|
5996
6281
|
runnerRefreshAbort.abort();
|
|
5997
6282
|
// A stop someone asked for is not a crash, even if it then runs past the shutdown deadline: forget the sessions now.
|
|
@@ -6007,7 +6292,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
6007
6292
|
for (const pending of checkpointWaits.values()) pending.done(undefined);
|
|
6008
6293
|
for (const p of permissions.values()) p.done(undefined);
|
|
6009
6294
|
try {
|
|
6010
|
-
await terminalReadsStopped;
|
|
6295
|
+
await Promise.all([terminalReadsStopped, codeFactsStopped]);
|
|
6011
6296
|
await terminalLaunches.settled();
|
|
6012
6297
|
// A peer can still be inside memory recall or its native handshake when kill arrives.
|
|
6013
6298
|
// Drain those starts before taking the final owned-process snapshot.
|
|
@@ -6108,8 +6393,8 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
6108
6393
|
// After a crash that recorded Pi, crash recovery starts it (#66): its recorded session first, a fresh one if that fails.
|
|
6109
6394
|
const piRecovered = !archivedPiContinuity && !!crashed?.peers.some((p) => definitionOf(p.peer)?.session.restoreOnStart);
|
|
6110
6395
|
if (piAutoStart() && !recoveryActive() && !piRecovered) autoStartPi();
|
|
6111
|
-
return { bus, token, port: server.port as number, stop, stopped: new Promise<void>((r) => (onStop = r)) };
|
|
6396
|
+
return { bus, token, port: server.port as number, bandEvaluations: () => bandEvaluations, stop, stopped: new Promise<void>((r) => (onStop = r)) };
|
|
6112
6397
|
} finally {
|
|
6113
|
-
if (!ready) for (const cleanup of startupCleanup.reverse()) { try { cleanup(); } catch { /* preserve startup error */ } }
|
|
6398
|
+
if (!ready) for (const cleanup of startupCleanup.reverse()) { try { await cleanup(); } catch { /* preserve startup error */ } }
|
|
6114
6399
|
}
|
|
6115
6400
|
}
|