@staix/agent-hub 0.12.43 → 0.12.44
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 +16 -3
- package/README.md +3 -3
- package/docs/adding-an-ai-tool.md +140 -0
- package/docs/agent-notes/README.md +5 -0
- package/docs/agent-notes/adapters.md +8 -3
- package/docs/agent-notes/budget.md +2 -2
- package/docs/agent-notes/daemon.md +9 -5
- package/docs/agent-notes/tasks.md +6 -2
- package/docs/agent-notes/tests.md +3 -1
- package/docs/operations.md +64 -5
- package/docs/quickstart.md +6 -2
- package/docs/security.md +5 -3
- package/docs/specs/2026-09-19-agent-hub-design.md +160 -4
- package/docs/verification/2026-10-21-0.12.44.md +54 -0
- package/docs/verification/2026-10-21-grok-config-acp-t0.md +30 -0
- package/docs/verified.json +34 -19
- package/package.json +1 -1
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +743 -229
- package/src/adapters/acp-owned-group.ts +124 -0
- package/src/adapters/acp.ts +92 -25
- package/src/adapters/agy/definition.ts +1 -3
- package/src/adapters/agy/entry.ts +4 -1
- package/src/adapters/channel-peer.ts +143 -0
- package/src/adapters/claude-channel.ts +7 -7
- package/src/adapters/codex-appserver.ts +16 -2
- package/src/adapters/configured-acp.ts +124 -0
- package/src/adapters/definitions/builtin-metadata.ts +37 -33
- package/src/adapters/definitions/native-channel-state.ts +41 -0
- package/src/adapters/definitions/native-cli-entry-kimi.ts +3 -1
- package/src/adapters/definitions/native-cli-preview-qwen.ts +1 -1
- package/src/adapters/definitions/native-identity-defaults.ts +14 -0
- package/src/adapters/definitions/qwen.ts +3 -8
- package/src/adapters/definitions/reviewer-variants.ts +3 -7
- package/src/adapters/jcode.ts +4 -0
- package/src/adapters/local-worker.ts +4 -0
- package/src/adapters/pi.ts +4 -0
- package/src/adapters/registry-types.ts +18 -16
- package/src/adapters/registry.ts +68 -6
- package/src/adapters/reviewer-print.ts +4 -0
- package/src/cli/agy-mcp.ts +3 -2
- package/src/cli/configured-acp-entry.ts +58 -0
- package/src/cli/console-state.ts +9 -4
- package/src/cli/facts-hook.ts +2 -1
- package/src/cli/help.ts +6 -0
- package/src/cli/identity.ts +7 -6
- package/src/cli/launch.ts +5 -4
- package/src/cli/main.ts +78 -27
- package/src/cli/memory.ts +13 -10
- package/src/cli/output.ts +1 -0
- package/src/cli/preview.ts +9 -0
- package/src/cli/recovery-guidance.ts +1 -1
- package/src/cli/terminal-recovery.ts +16 -13
- package/src/cli/upgrade-restore-state.ts +3 -2
- package/src/cli/upgrade-runtime.ts +21 -6
- package/src/cli/upgrade.ts +3 -2
- package/src/hub/board.ts +9 -6
- package/src/hub/budget.ts +9 -2
- package/src/hub/bus.ts +6 -2
- package/src/hub/claude-state.ts +2 -37
- package/src/hub/conductor.ts +8 -6
- package/src/hub/config-apply.ts +26 -14
- package/src/hub/config-reload.ts +39 -11
- package/src/hub/config-trust.ts +1 -1
- package/src/hub/context-window.ts +6 -0
- package/src/hub/control-client.ts +1 -1
- package/src/hub/crash.ts +8 -6
- package/src/hub/daemon.ts +341 -313
- package/src/hub/hub-tools.ts +36 -0
- package/src/hub/instance-config.ts +20 -11
- package/src/hub/instance-execution.ts +4 -4
- package/src/hub/instances.ts +45 -21
- package/src/hub/lifecycle-schema.ts +5 -3
- package/src/hub/memory-profile.ts +16 -3
- package/src/hub/no-writes.ts +50 -0
- package/src/hub/permission-mode.ts +9 -7
- package/src/hub/roster.ts +7 -4
- package/src/hub/routing.ts +21 -20
- package/src/hub/settings.ts +50 -16
- package/src/hub/snapshots.ts +26 -2
- package/src/hub/start-mode.ts +10 -8
- package/src/hub/task-sweep.ts +8 -2
- package/src/hub/tasks.ts +85 -17
- package/src/memory/capture.ts +2 -1
- package/src/ui/index.html +5 -0
- package/templates/AGENTS.block.md +1 -1
- package/templates/profiles/general.json +1 -1
- package/templates/profiles/research.json +1 -1
- package/templates/profiles/writing.json +1 -1
|
@@ -90,6 +90,7 @@ export interface VersionContext {
|
|
|
90
90
|
env: Readonly<Record<string, string>>;
|
|
91
91
|
}
|
|
92
92
|
|
|
93
|
+
/** Compatibility type for frozen A1 permission metadata, not a runtime permission port. */
|
|
93
94
|
export type NativeModeSpec =
|
|
94
95
|
| { kind: "acp-mode"; modeId: string }
|
|
95
96
|
| { kind: "codex-approval"; policy: string }
|
|
@@ -100,7 +101,6 @@ export type NativeModeSpec =
|
|
|
100
101
|
export interface QuotaReader {
|
|
101
102
|
source: string;
|
|
102
103
|
read(value: unknown): UsageWindow[];
|
|
103
|
-
resetOnWindow?: boolean;
|
|
104
104
|
}
|
|
105
105
|
|
|
106
106
|
export interface ContextReader {
|
|
@@ -145,12 +145,7 @@ export interface NativeFactsPort {
|
|
|
145
145
|
|
|
146
146
|
/** Only the frozen native seams are extensible; a definition cannot add an arbitrary daemon callback. */
|
|
147
147
|
export interface NativeHooks {
|
|
148
|
-
agentIdentity(init: unknown): void;
|
|
149
|
-
onAttach(instance: string): void;
|
|
150
|
-
onDetach(instance: string): void;
|
|
151
148
|
factsHook: NativeFactsPort;
|
|
152
|
-
conversationRevert(turnId: string): Promise<void>;
|
|
153
|
-
replaceSession(req: StartRequest): Promise<unknown>;
|
|
154
149
|
pluginInventory(ctx: { home: string; cwd?: string; env: Readonly<Record<string, string | undefined>> }): Addon[];
|
|
155
150
|
recallSource: string;
|
|
156
151
|
recallInjected: boolean;
|
|
@@ -182,8 +177,8 @@ export interface AdapterDefinition {
|
|
|
182
177
|
start: {
|
|
183
178
|
forms: readonly StartForm[];
|
|
184
179
|
defaultForm: StartForm;
|
|
185
|
-
|
|
186
|
-
launch(ctx: LaunchContext): LaunchPlan;
|
|
180
|
+
/** A real native argv planner; absent when startup belongs to a separate verified host port. */
|
|
181
|
+
launch?(ctx: LaunchContext): LaunchPlan;
|
|
187
182
|
create?(ctx: CreateContext): PeerAdapter;
|
|
188
183
|
daemonPort?: DaemonEngine;
|
|
189
184
|
preflight?(ctx: StartRequest): string | undefined;
|
|
@@ -203,16 +198,20 @@ export interface AdapterDefinition {
|
|
|
203
198
|
conductorStartArgs?(args: string[]): { mode?: StartForm } | Error;
|
|
204
199
|
};
|
|
205
200
|
identity: { mcp: "acp-session" | "project-file" | "env" | "channel-plugin" };
|
|
201
|
+
/** Declaration admission only; execution-root and runtime owner proof remain host responsibilities. */
|
|
202
|
+
instances?: { mode: "external" | "headless"; roles?: readonly string[] };
|
|
206
203
|
attach?: {
|
|
204
|
+
/** The authenticated external hello may claim this id; independent of MCP configuration shape. */
|
|
205
|
+
externalHello?: boolean;
|
|
207
206
|
pullOnlyForms?: readonly StartForm[];
|
|
208
207
|
recoveryLaunch?(instance: string): Record<string, unknown>;
|
|
209
208
|
pullOnlyNotice?: string;
|
|
210
209
|
admission?: { reservedRefusal: string; watchRefusal: string };
|
|
211
210
|
};
|
|
212
211
|
permission: {
|
|
213
|
-
|
|
212
|
+
/** Consumed configured-ACP mode capability; native runtime and instance ceiling use the same validated mapping. */
|
|
213
|
+
genericAcp?: { modes?: Partial<Record<PermissionMode, string>>; readOnly?: string };
|
|
214
214
|
grants?: Partial<Record<PermissionMode, string>>;
|
|
215
|
-
readOnly?: NativeModeSpec;
|
|
216
215
|
unmanaged?: string;
|
|
217
216
|
unmanagedForms?: readonly StartForm[];
|
|
218
217
|
unmanagedNote?: string;
|
|
@@ -273,12 +272,6 @@ export interface AdapterDefinition {
|
|
|
273
272
|
validateEffective?(raw: Record<string, unknown>, fail: (key: string, reason: string) => never): void;
|
|
274
273
|
};
|
|
275
274
|
capabilities?: { manifest?(peer: PeerAdapter): Promise<CapabilityManifest>; watch?: boolean };
|
|
276
|
-
init?: {
|
|
277
|
-
templateFragment?: string;
|
|
278
|
-
mcpFile?: string;
|
|
279
|
-
/** Pure planner; the CLI host owns every write and cleanup. */
|
|
280
|
-
mcpPlan?(ctx: { projectRoot: string; stateDir: string; entrypoint: string; form: StartForm }): { file: string; tracked: boolean; changed: boolean; action: string };
|
|
281
|
-
};
|
|
282
275
|
cli: {
|
|
283
276
|
command: string;
|
|
284
277
|
/** Configuration leaves that select this peer's next native launch command or provider profile. */
|
|
@@ -302,3 +295,12 @@ export interface AdapterDefinition {
|
|
|
302
295
|
|
|
303
296
|
/** Explicit project declarations, passed by the bound host. No process-global instance registration. */
|
|
304
297
|
export type KindMap = Readonly<Record<string, { readonly kind: string; readonly roles?: readonly string[]; readonly start_mode?: "tui" | "headless"; readonly review_mode?: "fresh" }>>;
|
|
298
|
+
|
|
299
|
+
/** Immutable per-project resolver; exported globals remain builtin-only. */
|
|
300
|
+
export interface ProjectAdapterCatalog {
|
|
301
|
+
readonly definitions: readonly AdapterDefinition[];
|
|
302
|
+
definitionOf(id: string, instances?: KindMap): AdapterDefinition | undefined;
|
|
303
|
+
kindOf(id: string, instances?: KindMap): string | undefined;
|
|
304
|
+
familyOf(id: string, instances?: KindMap): string | undefined;
|
|
305
|
+
isSupportedPeer(id: unknown, instances?: KindMap): id is string;
|
|
306
|
+
}
|
package/src/adapters/registry.ts
CHANGED
|
@@ -6,7 +6,8 @@ import { DAEMON_PORTS } from "./definitions/native-factories.ts";
|
|
|
6
6
|
export { DAEMON_PORTS };
|
|
7
7
|
import { NATIVE_BUILTIN_DEFINITIONS_WITHOUT_QWEN } from "./definitions/builtin-metadata.ts";
|
|
8
8
|
import { QWEN_DEFINITION } from "./definitions/qwen.ts";
|
|
9
|
-
import
|
|
9
|
+
import { configuredAcpDefinitions } from "./configured-acp.ts";
|
|
10
|
+
import type { AdapterDefinition, KindMap, ProjectAdapterCatalog } from "./registry-types.ts";
|
|
10
11
|
import { types } from "node:util";
|
|
11
12
|
|
|
12
13
|
export type * from "./registry-types.ts";
|
|
@@ -41,9 +42,9 @@ export const ADAPTERS: readonly AdapterDefinition[] = immutable([
|
|
|
41
42
|
const declarationOnlyDefinitions: readonly AdapterDefinition[] = immutable([]);
|
|
42
43
|
const definitions = new Map<string, AdapterDefinition>();
|
|
43
44
|
for (const definition of [...ADAPTERS, ...declarationOnlyDefinitions]) {
|
|
44
|
-
|
|
45
|
+
assertDefinitionCoherence(definition);
|
|
45
46
|
assertNativeCapabilityScope(definition);
|
|
46
|
-
if (definition.reviewerVariant) {
|
|
47
|
+
if (definition.reviewerVariant) { assertDefinitionCoherence(definition.reviewerVariant); assertNativeCapabilityScope(definition.reviewerVariant); }
|
|
47
48
|
if (definition.api !== ADAPTER_API || definitions.has(definition.kind)) throw new Error("invalid builtin adapter registry");
|
|
48
49
|
definitions.set(definition.kind, definition);
|
|
49
50
|
}
|
|
@@ -65,7 +66,7 @@ function ownField(value: unknown, key: string): unknown {
|
|
|
65
66
|
return field && Object.hasOwn(field, "value") && field.enumerable ? field.value : undefined;
|
|
66
67
|
}
|
|
67
68
|
/** Declaration reads never execute caller accessors or inherit another project's mapping. */
|
|
68
|
-
function declaredKind(id: string, instances: KindMap | undefined): string | undefined {
|
|
69
|
+
function declaredKind(id: string, instances: KindMap | undefined, catalog: ReadonlyMap<string, AdapterDefinition> = definitions): string | undefined {
|
|
69
70
|
if (!instances || typeof instances !== "object" || types.isProxy(instances) || Array.isArray(instances)
|
|
70
71
|
|| ![Object.prototype, null].includes(Object.getPrototypeOf(instances))) return undefined;
|
|
71
72
|
const field = Object.getOwnPropertyDescriptor(instances, id);
|
|
@@ -74,7 +75,7 @@ function declaredKind(id: string, instances: KindMap | undefined): string | unde
|
|
|
74
75
|
if (!entry || typeof entry !== "object" || types.isProxy(entry) || Array.isArray(entry)
|
|
75
76
|
|| ![Object.prototype, null].includes(Object.getPrototypeOf(entry))) return undefined;
|
|
76
77
|
const kind = Object.getOwnPropertyDescriptor(entry, "kind");
|
|
77
|
-
if (!kind || !Object.hasOwn(kind, "value") || !kind.enumerable || typeof kind.value !== "string" || !
|
|
78
|
+
if (!kind || !Object.hasOwn(kind, "value") || !kind.enumerable || typeof kind.value !== "string" || !catalog.has(kind.value)) return undefined;
|
|
78
79
|
const prefix = `${kind.value}-`;
|
|
79
80
|
return /^[a-z][a-z0-9-]{0,31}$/.test(id) && id.startsWith(prefix) && /^[a-z0-9][a-z0-9-]*$/.test(id.slice(prefix.length)) ? kind.value : undefined;
|
|
80
81
|
}
|
|
@@ -84,7 +85,10 @@ export function kindOf(id: string, instances?: KindMap): string | undefined {
|
|
|
84
85
|
}
|
|
85
86
|
export function definitionOf(id: string, instances?: KindMap): AdapterDefinition | undefined {
|
|
86
87
|
const kind = kindOf(id, instances);
|
|
87
|
-
|
|
88
|
+
return resolvedDefinition(id, kind, instances, definitions);
|
|
89
|
+
}
|
|
90
|
+
function resolvedDefinition(id: string, kind: string | undefined, instances: KindMap | undefined, catalog: ReadonlyMap<string, AdapterDefinition>): AdapterDefinition | undefined {
|
|
91
|
+
const definition = kind === undefined ? undefined : catalog.get(kind);
|
|
88
92
|
// #223: only a declared extra (never the builtin id) with start_mode "headless" runs as the kind's reviewer variant.
|
|
89
93
|
const declaration = ownField(instances, id), roles = ownField(declaration, "roles");
|
|
90
94
|
const reviewerOnly = Array.isArray(roles) && !types.isProxy(roles) && roles.length === 1 && Object.getOwnPropertyDescriptor(roles, "0")?.value === "reviewer";
|
|
@@ -96,6 +100,24 @@ export function familyOf(id: string, instances?: KindMap): string | undefined {
|
|
|
96
100
|
return definitionOf(id, instances)?.family;
|
|
97
101
|
}
|
|
98
102
|
|
|
103
|
+
/** Compile one captured, host-trusted declaration set; never register native policy globally. */
|
|
104
|
+
export function createProjectAdapterCatalog(rawAdapters?: unknown): ProjectAdapterCatalog {
|
|
105
|
+
const configured = configuredAcpDefinitions(rawAdapters, [...definitions.keys()]);
|
|
106
|
+
for (const definition of configured) { assertDefinitionCoherence(definition); assertNativeCapabilityScope(definition); }
|
|
107
|
+
const entries = Object.freeze([...ADAPTERS, ...configured]);
|
|
108
|
+
const catalog = new Map([...definitions, ...configured.map(definition => [definition.kind, definition] as const)]);
|
|
109
|
+
const kindOf = (id: string, instances?: KindMap): string | undefined => catalog.has(id) ? id : typeof id === "string" ? declaredKind(id, instances, catalog) : undefined;
|
|
110
|
+
const definitionOf = (id: string, instances?: KindMap): AdapterDefinition | undefined => resolvedDefinition(id, kindOf(id, instances), instances, catalog);
|
|
111
|
+
return Object.freeze({ definitions: entries, kindOf, definitionOf,
|
|
112
|
+
familyOf: (id: string, instances?: KindMap) => definitionOf(id, instances)?.family,
|
|
113
|
+
isSupportedPeer: (id: unknown, instances?: KindMap): id is string => {
|
|
114
|
+
if (typeof id !== "string") return false;
|
|
115
|
+
const kind = kindOf(id, instances), runtime = kind === undefined ? undefined : catalog.get(kind), resolved = definitionOf(id, instances);
|
|
116
|
+
return runtime !== undefined && resolved !== undefined && (kind === id || resolved === runtime || resolved.reviewer?.unavailable === undefined);
|
|
117
|
+
},
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
|
|
99
121
|
/** Compatibility projection order is observable in default role maps and planner selection. */
|
|
100
122
|
const ROLE_ORDER = ["claude", "codex", "kimi", "local", "pi", "qwen", "jcode", "agy"] as const;
|
|
101
123
|
export const PLANNER_PEERS = ["claude", "codex", "kimi", "qwen", "jcode"] as const;
|
|
@@ -126,6 +148,46 @@ export function assertDaemonEngine(definition: Pick<AdapterDefinition, "start">)
|
|
|
126
148
|
if (definition.start.create && engine === undefined || engine !== undefined && !Object.hasOwn(DAEMON_PORTS, engine)) throw new Error("invalid adapter daemon engine");
|
|
127
149
|
}
|
|
128
150
|
|
|
151
|
+
/** Structural registry contract only: native telemetry readers are never executed during loading. */
|
|
152
|
+
export function assertDefinitionCoherence(definition: AdapterDefinition): void {
|
|
153
|
+
const fail = (field: string, reason: string): never => { throw new Error(`${definition.kind}.${field}: ${reason}`); };
|
|
154
|
+
const nonempty = (value: unknown): value is string => typeof value === "string" && value.trim().length > 0;
|
|
155
|
+
assertDaemonEngine(definition);
|
|
156
|
+
if (definition.identity.mcp === "channel-plugin" && definition.transport !== "claude-channel" && definition.transport !== "ws-pull") {
|
|
157
|
+
fail("identity.mcp", "channel-plugin requires claude-channel or ws-pull transport");
|
|
158
|
+
}
|
|
159
|
+
if (definition.turnEnd === "native-hook" && definition.transport !== "claude-channel" && !definition.hooks?.factsHook) {
|
|
160
|
+
fail("turnEnd", "native-hook requires the Claude transport or a native factsHook");
|
|
161
|
+
}
|
|
162
|
+
const attach = definition.attach, pullOnly = attach?.pullOnlyForms;
|
|
163
|
+
if (pullOnly !== undefined) {
|
|
164
|
+
if (!Array.isArray(pullOnly) || pullOnly.some(form => !definition.start.forms.includes(form))) fail("attach.pullOnlyForms", "each pull-only form must be a supported start form");
|
|
165
|
+
}
|
|
166
|
+
if (definition.transport === "ws-pull" || definition.transport === "claude-channel" || definition.instances?.mode === "external") {
|
|
167
|
+
if (attach?.externalHello !== true) fail("attach.externalHello", "external transport or instance admission requires an explicit hello policy");
|
|
168
|
+
}
|
|
169
|
+
if (definition.transport === "ws-pull" && !pullOnly?.length) fail("attach.pullOnlyForms", "ws-pull requires a nonempty pull-only contract");
|
|
170
|
+
if (attach?.admission !== undefined) {
|
|
171
|
+
if (!attach.admission || typeof attach.admission !== "object") fail("attach.admission", "admission must declare its refusal policy");
|
|
172
|
+
if (!definition.start.forms.includes("tui") || !pullOnly?.includes("tui")) fail("attach.admission", "native admission requires a supported pull-only TUI form");
|
|
173
|
+
for (const field of ["reservedRefusal", "watchRefusal"] as const) {
|
|
174
|
+
if (!nonempty(attach.admission[field])) fail(`attach.admission.${field}`, "admission refusal text must be nonempty");
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
if (definition.budget !== undefined) {
|
|
178
|
+
if (!nonempty(definition.budget?.account)) fail("budget.account", "declared budget account must be a nonempty string");
|
|
179
|
+
for (const field of ["reader", "context"] as const) {
|
|
180
|
+
const reader = definition.budget[field];
|
|
181
|
+
if (reader === undefined) continue;
|
|
182
|
+
if (!reader || typeof reader !== "object") fail(`budget.${field}`, "telemetry reader must be an object");
|
|
183
|
+
if (!nonempty(reader.source)) fail(`budget.${field}.source`, "telemetry source must be a nonempty string");
|
|
184
|
+
if (typeof reader.read !== "function") fail(`budget.${field}.read`, "telemetry read must be callable");
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
if (definition.reviewerVariant && !definition.reviewerVariant.reviewer) fail("reviewerVariant.reviewer", "reviewer variant lacks reviewer policy");
|
|
188
|
+
if (definition.reviewer?.unavailable && (definition.start.create || definition.reviewer.backend)) fail("reviewer", "unavailable reviewer must remain metadata-only");
|
|
189
|
+
}
|
|
190
|
+
|
|
129
191
|
/** Host-global native quirks are not capabilities a config-only or external definition may opt into. */
|
|
130
192
|
export function assertNativeCapabilityScope(definition: AdapterDefinition): void {
|
|
131
193
|
if (fixedBuiltinDefinitions.has(definition)) return;
|
|
@@ -380,3 +380,7 @@ export class ReviewerPrintPeer extends BasePeer {
|
|
|
380
380
|
if (this.child === child) { await this.childCleanup?.(); this.child = undefined; this.childStop = undefined; this.childCleanup = undefined; this.childCurrent = undefined; }
|
|
381
381
|
}
|
|
382
382
|
}
|
|
383
|
+
|
|
384
|
+
export function isReviewerPrintPeer(value: unknown): value is ReviewerPrintPeer {
|
|
385
|
+
return value instanceof ReviewerPrintPeer;
|
|
386
|
+
}
|
package/src/cli/agy-mcp.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { AGY_DEFINITION } from "../adapters/agy/definition.ts";
|
|
1
2
|
import type { Stats } from "node:fs";
|
|
2
3
|
import { randomUUID } from "node:crypto";
|
|
3
4
|
import { closeSync, fstatSync, lstatSync, mkdirSync, openSync, readFileSync, renameSync, rmdirSync, unlinkSync, writeFileSync } from "node:fs";
|
|
@@ -53,7 +54,7 @@ function shellUnquote(value: string): string | undefined {
|
|
|
53
54
|
}
|
|
54
55
|
|
|
55
56
|
function validateOptions(options: AgyMcpOptions) {
|
|
56
|
-
const peer = options.peer ??
|
|
57
|
+
const peer = options.peer ?? AGY_DEFINITION.kind;
|
|
57
58
|
if (!/^[a-z][a-z0-9-]{0,31}$/.test(peer)) throw new Error("agy peer id is invalid");
|
|
58
59
|
for (const [name, value] of Object.entries({ projectRoot: options.projectRoot, stateDir: options.stateDir, serverJs: options.serverJs, hookScript: options.hookScript })) {
|
|
59
60
|
if (typeof value !== "string" || !isAbsolute(value) || value.length > 4096 || /[\u0000-\u001f\u007f]/.test(value)) throw new Error(`${name} must be an absolute control-free path`);
|
|
@@ -326,7 +327,7 @@ export function cleanupAgyMcp(options: AgyMcpOptions): void {
|
|
|
326
327
|
if (!present.length) return;
|
|
327
328
|
if (present.length !== FILES.length) throw new Error("managed agy plugin is incomplete; inspect it before cleanup");
|
|
328
329
|
const currentHooks = parseJson(readFileSync(join(plan.root, HOOKS_FILE)), HOOKS_FILE);
|
|
329
|
-
const launch = managedHookLaunch(currentHooks, options, options.peer ??
|
|
330
|
+
const launch = managedHookLaunch(currentHooks, options, options.peer ?? AGY_DEFINITION.kind);
|
|
330
331
|
if (!launch || launch.instanceId !== options.instanceId || launch.launchId !== options.launchId) return;
|
|
331
332
|
// A stale cleanup never removes files a newer config update has replaced.
|
|
332
333
|
if (plan.changed) return;
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import type { ProjectAdapterCatalog } from "../adapters/registry.ts";
|
|
2
|
+
import type { InstancesConfig } from "../hub/instances.ts";
|
|
3
|
+
import { requireActiveInstance, requirePeerInstances } from "./launch.ts";
|
|
4
|
+
import { configuredAcpPreview } from "./preview.ts";
|
|
5
|
+
|
|
6
|
+
export interface ConfiguredAcpSelection { peer: string; mode: "headless"; sessionId?: string; preview: boolean }
|
|
7
|
+
export function parseConfiguredAcpArguments(kind: string, raw: readonly string[], catalog: ProjectAdapterCatalog, instances?: InstancesConfig): ConfiguredAcpSelection {
|
|
8
|
+
const definition = catalog.definitionOf(kind);
|
|
9
|
+
if (!definition?.permission.genericAcp) throw new Error("unknown configured ACP kind");
|
|
10
|
+
const values = new Map<string, string>();
|
|
11
|
+
let preview = false;
|
|
12
|
+
for (let index = 0; index < raw.length; index++) {
|
|
13
|
+
const arg = raw[index]!;
|
|
14
|
+
if (arg === "--print-command" || arg === "--dry-run") { if (preview) throw new Error("preview option may appear only once"); preview = true; continue; }
|
|
15
|
+
const equal = arg.indexOf("="), flag = equal < 0 ? arg : arg.slice(0, equal);
|
|
16
|
+
if (!["--as", "--mode", "--session-id"].includes(flag) || values.has(flag)) throw new Error("configured ACP accepts only --mode headless, --as, --session-id and one preview option");
|
|
17
|
+
const value = equal < 0 ? raw[++index] : arg.slice(equal + 1);
|
|
18
|
+
if (!value || value.startsWith("--") || Buffer.byteLength(value) > 4096 || /[\p{Cc}\p{Cf}]/u.test(value)) throw new Error(`${flag} needs a bounded control-free value`);
|
|
19
|
+
values.set(flag, value);
|
|
20
|
+
}
|
|
21
|
+
if (values.has("--mode") && values.get("--mode") !== "headless") throw new Error("configured ACP supports headless only");
|
|
22
|
+
const peer = values.get("--as") ?? kind;
|
|
23
|
+
if (values.has("--as") && (!instances || !Object.hasOwn(instances, peer) || instances[peer]?.kind !== kind || !catalog.definitionOf(peer, instances))) throw new Error("--as must name a declared instance of this configured kind");
|
|
24
|
+
return { peer, mode: "headless", ...(values.has("--session-id") ? { sessionId: values.get("--session-id")! } : {}), preview };
|
|
25
|
+
}
|
|
26
|
+
export interface ConfiguredAcpControl {
|
|
27
|
+
features: readonly string[];
|
|
28
|
+
request(message: Record<string, unknown>, timeout?: number): Promise<any>;
|
|
29
|
+
close(): void;
|
|
30
|
+
}
|
|
31
|
+
export async function requireActiveConfiguredAcp(hub: ConfiguredAcpControl, kind: string, peer: string, warn?: (text: string) => void): Promise<void> {
|
|
32
|
+
if (!hub.features.includes("configured_acp")) throw new Error("running hub lacks configured_acp; upgrade and restart it first");
|
|
33
|
+
if (peer !== kind) requirePeerInstances(hub.features);
|
|
34
|
+
const reply = await hub.request({ t: "status" });
|
|
35
|
+
const active = reply.status?.configuredAdapters;
|
|
36
|
+
if (!Array.isArray(active) || !active.includes(kind)) throw new Error(`running hub has not loaded ${kind}; restart it after declaring the adapter`);
|
|
37
|
+
if (peer !== kind) requireActiveInstance(reply.status, peer);
|
|
38
|
+
const namespace = `adapters.${kind}`;
|
|
39
|
+
const retained = [reply.status?.retainedConfig, reply.status?.pendingRestart].some(rows => Array.isArray(rows) && rows.some(row => {
|
|
40
|
+
const key = row && typeof row === "object" ? row.key : undefined;
|
|
41
|
+
return typeof key === "string" && (key === "adapters" || key === namespace || key.startsWith(`${namespace}.`));
|
|
42
|
+
}));
|
|
43
|
+
if (retained) warn?.(`${kind}: configured adapter changes are retained; this start uses the running definition. Restart the hub to apply the file's definition.`);
|
|
44
|
+
}
|
|
45
|
+
export async function runConfiguredAcpEntry(context: {
|
|
46
|
+
kind: string; raw: readonly string[]; catalog: ProjectAdapterCatalog; instances?: InstancesConfig;
|
|
47
|
+
connect(): Promise<ConfiguredAcpControl>; print(text: string): void; operationId?: string;
|
|
48
|
+
}): Promise<void> {
|
|
49
|
+
const selected = parseConfiguredAcpArguments(context.kind, context.raw, context.catalog, context.instances);
|
|
50
|
+
if (selected.preview) { context.print(JSON.stringify(configuredAcpPreview(context.kind, selected.peer, selected.sessionId !== undefined), null, 2)); return; }
|
|
51
|
+
const hub = await context.connect();
|
|
52
|
+
try {
|
|
53
|
+
await requireActiveConfiguredAcp(hub, context.kind, selected.peer, context.print);
|
|
54
|
+
const reply = await hub.request({ t: "start", peer: selected.peer, args: { mode: "headless", ...(selected.sessionId ? { sessionId: selected.sessionId } : {}) }, ...(context.operationId ? { operationId: context.operationId } : {}) }, 70_000);
|
|
55
|
+
if (!reply.ok) throw new Error(typeof reply.error === "string" ? reply.error : "configured ACP start refused");
|
|
56
|
+
context.print(reply.already ? `${selected.peer} is already attached` : `${selected.peer} attached (headless); use ahub say @${selected.peer} and the console for approvals`);
|
|
57
|
+
} finally { hub.close(); }
|
|
58
|
+
}
|
package/src/cli/console-state.ts
CHANGED
|
@@ -2,7 +2,7 @@ import { consoleNeeds, characterAttentionLine, attentionCommand } from "./attent
|
|
|
2
2
|
import { CHARACTERS, petFrame, petSize, petFooterRows, type CharacterId, type PetSize, type PetPose } from "../pet/frame.ts";
|
|
3
3
|
import { PEER_PALETTE } from "../ui/peer-palette.ts";
|
|
4
4
|
import { PERMISSION_MODES, permissionBoundary } from "../hub/permission-mode.ts";
|
|
5
|
-
import { PLANNER_PEERS, definitionOf, nativeStreamTone } from "../adapters/registry.ts";
|
|
5
|
+
import { PLANNER_PEERS, definitionOf, nativeStreamTone, createProjectAdapterCatalog } from "../adapters/registry.ts";
|
|
6
6
|
import { taskProgress } from "../ui/task-progress.ts";
|
|
7
7
|
import type { ProgressStage } from "../ui/task-progress.ts";
|
|
8
8
|
import { sanitize } from "../hub/envelope.ts";
|
|
@@ -91,7 +91,7 @@ export function syncPermissionDefaults(s: ConsoleState, rows: unknown): boolean
|
|
|
91
91
|
return true;
|
|
92
92
|
}
|
|
93
93
|
export function permissionDefaultText(row: Pick<PermissionDefault, "peer" | "source">): string {
|
|
94
|
-
return `never-ask default for ${row.peer} from ${quoted(row.source)}\n${permissionBoundary(row.peer)}\nUntil you answer y, this default stays ask. y enables; n cancels for this hub; Esc defers.`;
|
|
94
|
+
return `never-ask default for ${row.peer} from ${quoted(row.source)}\n${permissionBoundary(row.peer, undefined, createProjectAdapterCatalog())}\nUntil you answer y, this default stays ask. y enables; n cancels for this hub; Esc defers.`;
|
|
95
95
|
}
|
|
96
96
|
/** Strip terminal controls before any daemon or child output reaches a terminal. Preserve printable Unicode. */
|
|
97
97
|
export function terminalText(value: unknown): string {
|
|
@@ -471,6 +471,8 @@ export function reduceConsole(state: ConsoleState, key: string, now = Date.now()
|
|
|
471
471
|
if (s.settingsEdit) {
|
|
472
472
|
const edit = { ...s.settingsEdit }; s.settingsEdit = edit;
|
|
473
473
|
if (key === "\x1b") { s.settingsEdit = undefined; return done(); }
|
|
474
|
+
const locked = s.settingsRows?.find(row => row.key === edit.key)?.locked;
|
|
475
|
+
if (locked) { s.settingsEdit = undefined; notify(s, locked, now); return done(); }
|
|
474
476
|
if (edit.phase === "preview") return done();
|
|
475
477
|
if (edit.phase === "confirm") {
|
|
476
478
|
s.settingsEdit = undefined;
|
|
@@ -632,6 +634,8 @@ export function reduceConsole(state: ConsoleState, key: string, now = Date.now()
|
|
|
632
634
|
const row = selectedPanelRow(s);
|
|
633
635
|
if (key === "u" && !s.settingsUndo) { notify(s, "nothing to undo", now); return done(); }
|
|
634
636
|
if (key === "e" && !row) return done();
|
|
637
|
+
const locked = (key === "e" ? row : s.settingsRows?.find(row => row.key === s.settingsUndo))?.locked;
|
|
638
|
+
if (locked) { notify(s, locked, now); return done(); }
|
|
635
639
|
const token = (s.settingsSequence ?? 0) + 1; s.settingsSequence = token;
|
|
636
640
|
s.settingsEdit = { token, undo: key === "u", ...(key === "e" ? { key: row.key } : {}), phase: key === "u" ? "preview" : "value", text: key === "e" ? settingValueText(row.fileValue) : "" };
|
|
637
641
|
if (key === "u") effects.push({ type: "settings_preview", token, undo: true });
|
|
@@ -975,6 +979,7 @@ export function renderConsoleLines(s: ConsoleState, columns: number, rows = 24,
|
|
|
975
979
|
if (p.paused) summary.push(span(" paused", "attention"));
|
|
976
980
|
if (typeof p.importantWaitingSince === "number") summary.push(span(` important waiting ${duration(now - p.importantWaitingSince)}`, "attention")); // #572 AC1: a busy ACP peer that cannot be steered
|
|
977
981
|
if (p.toolsOnly) summary.push(span(definitionOf(id)?.attach?.pullOnlyForms?.includes("tui") ? ` pull-only, read ${typeof p.lastInboxAt === "number" ? relative(p.lastInboxAt, now) : "never"}` : " tools-only: ahub claude", "attention"));
|
|
982
|
+
if (typeof p.statusNotice?.message === "string" && p.statusNotice.message) summary.push(span(` ${flat(p.statusNotice.message)}`, "attention"));
|
|
978
983
|
}
|
|
979
984
|
if (s.pendingRestart?.length) summary.push(span(`${summary.length ? " | " : ""}restart pending: ${s.pendingRestart.map(row => row.key).join(", ")}`, "attention"));
|
|
980
985
|
if (s.retainedConfig?.length) summary.push(span(`${summary.length ? " | " : ""}config retained: ${s.retainedConfig.map(row => row.key).join(", ")}; run ahub settings reload`, "attention"));
|
|
@@ -988,7 +993,7 @@ export function renderConsoleLines(s: ConsoleState, columns: number, rows = 24,
|
|
|
988
993
|
const neverPeer = s.confirm?.type === "permission_default" ? s.confirm.peer : s.confirm?.type === "command" && s.confirm.args[0] === "permission" && s.confirm.args[2] === "never-ask" ? s.confirm.args[1] : undefined;
|
|
989
994
|
const defaultSource = s.confirm?.type === "permission_default" ? s.confirm.source : undefined;
|
|
990
995
|
if (s.confirm?.type === "permission_default") prompt = `enable never-ask default for ${s.confirm.peer}? y/n Esc defer`;
|
|
991
|
-
const footer = [fitLine(neverPeer ? [span(permissionBoundary(neverPeer), "attention")] : summary, columns),
|
|
996
|
+
const footer = [fitLine(neverPeer ? [span(permissionBoundary(neverPeer, undefined, createProjectAdapterCatalog()), "attention")] : summary, columns),
|
|
992
997
|
fitLine(defaultSource !== undefined ? [span(`source ${quoted(defaultSource.split(/[\\/]/).at(-1))}; stays ask until y`, "attention")] : approvals, columns),
|
|
993
998
|
fitLine([span(prompt, s.confirm || s.optionChoice ? "attention" : undefined)], columns)];
|
|
994
999
|
const rule = [span("-".repeat(Math.max(0, columns)), "muted")];
|
|
@@ -1006,7 +1011,7 @@ export function renderConsoleLines(s: ConsoleState, columns: number, rows = 24,
|
|
|
1006
1011
|
const noticeLines = noticeShown && s.petEnabled && petSize(s.petSize, columns, rows) !== "line" ? wrap(`Notice: ${s.notice}`, columns).slice(0, Math.max(0, bodyHeight - 6)).map(text => [span(text, s.noticeTone ?? "failure")]) : [];
|
|
1007
1012
|
const height = bodyHeight - noticeLines.length;
|
|
1008
1013
|
const lines: Span[][] = [];
|
|
1009
|
-
if (s.confirm?.type === "permission_default") lines.push(...fieldLines([["default", `${s.confirm.peer} never-ask`], ["source", s.confirm.source], ["bounds", permissionBoundary(s.confirm.peer)], ["decision", "y enables; n cancels for this hub; Esc defers; until then ask"]], columns, now).flat());
|
|
1014
|
+
if (s.confirm?.type === "permission_default") lines.push(...fieldLines([["default", `${s.confirm.peer} never-ask`], ["source", s.confirm.source], ["bounds", permissionBoundary(s.confirm.peer, undefined, createProjectAdapterCatalog())], ["decision", "y enables; n cancels for this hub; Esc defers; until then ask"]], columns, now).flat());
|
|
1010
1015
|
else if (s.settingsEdit) {
|
|
1011
1016
|
const edit = s.settingsEdit;
|
|
1012
1017
|
const row = s.settingsRows?.find(row => row.key === edit.key);
|
package/src/cli/facts-hook.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
|
+
import { claudePeer as defaultChannelPeer } from "../hub/claude-state.ts";
|
|
2
3
|
import { definitionOf } from "../adapters/registry.ts";
|
|
3
4
|
// Managed Claude hook transport for turn-free facts, native observations and runtime permissions.
|
|
4
5
|
// PreToolUse preserves facts additionalContext and adds permission decisions; other phases observe only.
|
|
@@ -101,7 +102,7 @@ export async function factsHook(stdin: string, stateDir: string, peer: string, t
|
|
|
101
102
|
if (import.meta.main) {
|
|
102
103
|
try {
|
|
103
104
|
const stateDir = process.env.AGENTHUB_STATE_DIR;
|
|
104
|
-
const out = stateDir ? await factsHook(await Bun.stdin.text(), stateDir, process.env.AGENTHUB_PEER_ID ??
|
|
105
|
+
const out = stateDir ? await factsHook(await Bun.stdin.text(), stateDir, process.env.AGENTHUB_PEER_ID ?? defaultChannelPeer(undefined), 2000, process.env.AGENTHUB_HOOK_EVENT) : undefined;
|
|
105
106
|
if (out) console.log(out);
|
|
106
107
|
} catch {
|
|
107
108
|
// a hook that fails must not get in the way of the tool call
|
package/src/cli/help.ts
CHANGED
|
@@ -48,6 +48,7 @@ const SECTIONS: [string, [usage: string, description: string][]][] = [
|
|
|
48
48
|
["ahub recovery-run <operation-id>", "internal: the detached runner of an upgrade, restart or recovery resume"],
|
|
49
49
|
]],
|
|
50
50
|
["Agents", [
|
|
51
|
+
["ahub <configured-kind> [--mode headless] [--as <instance>] [--session-id <id>] [--print-command | --dry-run]", "start a trusted config.local.json ACP kind (default off roster); native argv is withheld in previews"],
|
|
51
52
|
["ahub claude [--print-command] [--unattended] [args...]", "launch Claude Code with the hub channel"],
|
|
52
53
|
["ahub codex [--print-command] [--unattended] [args...]", "start the Codex adapter and attach the TUI"],
|
|
53
54
|
["ahub kimi [--print-command] [--mode tui|headless] [--model <alias>]", "default: Kimi's own TUI, pull-only via hub_inbox; no push, steer, watchdog, console approvals or automatic quota handoff. Select headless for ACP"],
|
|
@@ -178,3 +179,8 @@ export function renderHelp(columns: number, color: boolean, command?: string): s
|
|
|
178
179
|
}
|
|
179
180
|
return lines.map(line => paint(line, color)).join("\n");
|
|
180
181
|
}
|
|
182
|
+
|
|
183
|
+
/** Body-free configured command guidance; no native argv or environment values enter help. */
|
|
184
|
+
export function configuredAcpHelp(kind: string): string {
|
|
185
|
+
return `ahub ${kind} [--mode headless] [--as <instance>] [--session-id <id>] [--print-command | --dry-run]\nConfigured ACP runs headless through the hub, with native permissions and NO hub sandbox.\nDeclare it in untracked .agenthub/config.local.json, restart the hub, then enable roster.${kind}.\nPreview withholds native argv and starts nothing. Native/model overrides and --unattended are refused.`;
|
|
186
|
+
}
|
package/src/cli/identity.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
|
+
import { nativeCliMarkerPeer } from "../adapters/definitions/native-identity-defaults.ts";
|
|
1
2
|
import { recoveryPresentationFlag } from "./recovery-presentation-options.ts";
|
|
2
|
-
import { definitionOf, kindOf, type KindMap } from "../adapters/registry.ts";
|
|
3
|
+
import { definitionOf, kindOf, type KindMap, type ProjectAdapterCatalog } from "../adapters/registry.ts";
|
|
3
4
|
|
|
4
5
|
/** Recovery reads this declaration before delegating to another package's CLI. */
|
|
5
6
|
export const LIVE_TEST_SUPPORTED = true;
|
|
@@ -42,7 +43,7 @@ export function detectCliIdentity(env: Readonly<Record<string, string | undefine
|
|
|
42
43
|
if (codex !== undefined && (!codex || /\s/.test(codex))) return invalid("invalid CODEX_THREAD_ID marker");
|
|
43
44
|
if (session !== undefined && claude === undefined) return invalid("CLAUDE_CODE_SESSION_ID without CLAUDECODE marker");
|
|
44
45
|
if (claude !== undefined && codex !== undefined) return invalid("conflicting native agent markers");
|
|
45
|
-
const native = claude !== undefined
|
|
46
|
+
const native = nativeCliMarkerPeer(claude !== undefined, codex !== undefined);
|
|
46
47
|
if (hub !== undefined && native !== undefined && (kindOf(hub, instances) ?? hub) !== native) return invalid("AGENTHUB_PEER_ID conflicts with native agent marker");
|
|
47
48
|
const peer = hub ?? native;
|
|
48
49
|
return peer === undefined ? { role: "console" } : { role: "tools", peer };
|
|
@@ -51,9 +52,9 @@ export function detectCliIdentity(env: Readonly<Record<string, string | undefine
|
|
|
51
52
|
export type PeerCommandAccess = "allowed" | "conductor" | "console";
|
|
52
53
|
|
|
53
54
|
/** A closed list: unknown commands cannot acquire console authority through an agent shell. */
|
|
54
|
-
export function classifyPeerCommand(command: string, args: readonly string[]): PeerCommandAccess {
|
|
55
|
+
export function classifyPeerCommand(command: string, args: readonly string[], catalog?: ProjectAdapterCatalog): PeerCommandAccess {
|
|
55
56
|
if (command === "recovery" && recoveryAction(args) === "reattach") return "console";
|
|
56
|
-
const native = definitionOf(command);
|
|
57
|
+
const native = catalog?.definitionOf(command) ?? definitionOf(command);
|
|
57
58
|
if (native?.cli.personOnlyFlags?.some(flag => args.includes(flag))) return "console";
|
|
58
59
|
if (command === "pet" && args.includes("--window")) return "console";
|
|
59
60
|
if (command === "permission" || command === "stop" || command === "watch") return "console";
|
|
@@ -82,11 +83,11 @@ export function classifyPeerCommand(command: string, args: readonly string[]): P
|
|
|
82
83
|
}
|
|
83
84
|
|
|
84
85
|
/** No argument text is included: notices and refusal logs must not copy task text or credentials. */
|
|
85
|
-
export function cliCommandLabel(command: string, args: readonly string[]): string {
|
|
86
|
+
export function cliCommandLabel(command: string, args: readonly string[], catalog?: ProjectAdapterCatalog): string {
|
|
86
87
|
if (command === "backlog") return ["list", "add", "pull", "pull-ready", "split", "return", "plan", "pin", "undo"].includes(args[0] ?? "") ? `backlog ${args[0]}` : "backlog";
|
|
87
88
|
if (command === "board" && args[0] === "import") return "board import";
|
|
88
89
|
if (command === "release") return ["new", "list", "show", "plan", "activate", "freeze", "ship", "drop"].includes(args[0] ?? "") ? `release ${args[0]}` : "release";
|
|
89
|
-
if (!definitionOf(command) && !["upgrade", "restart", "recovery", "recovery-run", "help", "--help", "--version", "version", "completion", "projects", "ui", "manager", "setup", "init", "daemon", "up", "console", "stop", "models", "say", "queue", "tail", "watch", "budget", "board", "task", "review", "remember", "ask", "route", "pause", "resume", "permit", "permission", "settings", "roster", "status", "pet", "logs", "export", "report", "research", "memory", "goal", "bench", "sdlc", "role", "facts", "check-path", "turns", "undo", "kill", "reset", "doctor"].includes(command)) return "unknown";
|
|
90
|
+
if (!(catalog?.definitionOf(command) ?? definitionOf(command)) && !["upgrade", "restart", "recovery", "recovery-run", "help", "--help", "--version", "version", "completion", "projects", "ui", "manager", "setup", "init", "daemon", "up", "console", "stop", "models", "say", "queue", "tail", "watch", "budget", "board", "task", "review", "remember", "ask", "route", "pause", "resume", "permit", "permission", "settings", "roster", "status", "pet", "logs", "export", "report", "research", "memory", "goal", "bench", "sdlc", "role", "facts", "check-path", "turns", "undo", "kill", "reset", "doctor"].includes(command)) return "unknown";
|
|
90
91
|
if (["task", "queue", "budget", "route", "recovery", "models", "projects", "research", "memory", "goal", "bench"].includes(command)) {
|
|
91
92
|
const sub = command === "bench" || command === "memory" || command === "goal" ? args.find((arg) => arg !== "--json") : args[0];
|
|
92
93
|
if (sub && ["propose", "show", "assign", "unassign", "escalate", "label", "list", "resolve", "resume", "set", "execution", "explain", "status", "abort", "dispose", "reattach", "setup", "start", "stop", "remove", "export", "backfill", "addons", "run", "report", "compare", "search", "graph", "promote", "demote", "forget", "supersede", "dedupe", "decay", "rebuild", "sync", "machine", "import", "suggestions", "confirm", "reject", "new", "link", "state", "done", "take"].includes(sub)) return `${command} ${sub}`;
|
package/src/cli/launch.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { claudePeer as defaultChannelPeer } from "../hub/claude-state.ts";
|
|
1
2
|
// Launchers inject only the flags the hub owns and refuse user-supplied duplicates.
|
|
2
3
|
import { claudeStateFile } from "../hub/claude-state.ts";
|
|
3
4
|
import { mkdirSync, writeFileSync, renameSync, unlinkSync, lstatSync, readFileSync, openSync, closeSync, readSync, accessSync, constants, linkSync } from "node:fs";
|
|
@@ -159,7 +160,7 @@ async function withClaudeLaunchRecord(stateDir: string, action: () => void): Pro
|
|
|
159
160
|
}
|
|
160
161
|
|
|
161
162
|
function writeClaudeLaunchRecord(stateDir: string, record: Record<string, unknown>): void {
|
|
162
|
-
const file = join(stateDir, claudeStateFile("claude-launch", typeof record.peer === "string" ? record.peer :
|
|
163
|
+
const file = join(stateDir, claudeStateFile("claude-launch", typeof record.peer === "string" ? record.peer : defaultChannelPeer(undefined))), temp = `${file}.${randomUUID()}.tmp`;
|
|
163
164
|
try { writeFileSync(temp, JSON.stringify(record), { mode: 0o600 }); renameSync(temp, file); }
|
|
164
165
|
finally { try { unlinkSync(temp); } catch { /* the rename normally consumed it */ } }
|
|
165
166
|
}
|
|
@@ -202,7 +203,7 @@ export async function recordClaudeNative(stateDir: string, launch: ClaudeLaunchR
|
|
|
202
203
|
const publication = await withClaudeLaunchRecord(stateDir, () => {
|
|
203
204
|
let current: ClaudeLaunchRecord;
|
|
204
205
|
try {
|
|
205
|
-
const file = join(stateDir, claudeStateFile("claude-launch", launch.peer ??
|
|
206
|
+
const file = join(stateDir, claudeStateFile("claude-launch", launch.peer ?? defaultChannelPeer(undefined))), stat = lstatSync(file);
|
|
206
207
|
if (!stat.isFile() || stat.isSymbolicLink() || stat.nlink !== 1 || (stat.mode & 0o777) !== 0o600 || !process.getuid || stat.uid !== process.getuid()) return;
|
|
207
208
|
current = JSON.parse(readFileSync(file, "utf8"));
|
|
208
209
|
} catch { return; }
|
|
@@ -219,7 +220,7 @@ export async function recordClaudeNative(stateDir: string, launch: ClaudeLaunchR
|
|
|
219
220
|
writeFileSync(temp, JSON.stringify({ ...launch, nativePid: pid, nativeSignature: signature, nativeIdentity: "direct-child" }), { mode: 0o600, flag: "wx" });
|
|
220
221
|
renameSync(temp, file);
|
|
221
222
|
} finally { try { unlinkSync(temp); } catch { /* atomic rename consumed our private temporary file */ } }
|
|
222
|
-
try { recorded = sameClaudeLaunch(JSON.parse(readFileSync(join(stateDir, claudeStateFile("claude-launch", launch.peer ??
|
|
223
|
+
try { recorded = sameClaudeLaunch(JSON.parse(readFileSync(join(stateDir, claudeStateFile("claude-launch", launch.peer ?? defaultChannelPeer(undefined))), "utf8")), launch); } catch { /* replaced or unreadable managed record */ }
|
|
223
224
|
});
|
|
224
225
|
if (publication.warning) report?.(publication.warning);
|
|
225
226
|
return publication.recorded && recorded;
|
|
@@ -333,7 +334,7 @@ export function buildLaunch(
|
|
|
333
334
|
): Launch {
|
|
334
335
|
const adapter = definitionOf(tool);
|
|
335
336
|
if (!adapter) throw new Error("native launch definition unavailable");
|
|
336
|
-
const { metadata: _metadata, ...launch } = adapter.start.launch({
|
|
337
|
+
const { metadata: _metadata, ...launch } = adapter.start.launch!({
|
|
337
338
|
instance: ctx.instance ?? tool, form: "tui", projectRoot: process.cwd(), stateDir: ctx.facts?.stateDir ?? ctx.statusLine?.stateDir ?? "",
|
|
338
339
|
entrypoint: resolve(import.meta.dir, "main.js"), userArgs, env: {}, ...ctx,
|
|
339
340
|
});
|