maqcli 0.4.0 → 0.6.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/dist/core/capabilities.d.ts +2 -0
- package/dist/core/cli-probe.d.ts +44 -0
- package/dist/core/cli-probe.js +85 -0
- package/dist/core/command-catalog.js +1 -0
- package/dist/core/config-store.d.ts +8 -0
- package/dist/core/config-store.js +28 -1
- package/dist/core/exec.d.ts +9 -0
- package/dist/core/exec.js +7 -2
- package/dist/core/launcher.d.ts +6 -0
- package/dist/core/launcher.js +61 -20
- package/dist/core/orchestrator.d.ts +5 -0
- package/dist/core/orchestrator.js +8 -0
- package/dist/core/permissions.d.ts +80 -0
- package/dist/core/permissions.js +147 -0
- package/dist/core/providers-catalog.d.ts +1 -0
- package/dist/core/providers-catalog.js +18 -0
- package/dist/core/sandbox.d.ts +14 -1
- package/dist/core/sandbox.js +49 -1
- package/dist/core/security.d.ts +113 -0
- package/dist/core/security.js +303 -0
- package/dist/core/session.d.ts +2 -0
- package/dist/core/session.js +1 -0
- package/dist/core/skills.d.ts +9 -0
- package/dist/core/skills.js +10 -2
- package/dist/core/tools.d.ts +2 -0
- package/dist/core/tools.js +7 -1
- package/dist/index.js +110 -2
- package/dist/phases/scout.js +16 -0
- package/dist/server/daemon.js +42 -1
- package/dist/server/webui.js +144 -1
- package/package.json +1 -1
|
@@ -21,6 +21,8 @@ export interface TieredModel {
|
|
|
21
21
|
tier: CapabilityTier;
|
|
22
22
|
vision?: boolean;
|
|
23
23
|
longContext?: boolean;
|
|
24
|
+
/** Roles this model is suited for (plan|code|review|summarize|fan-out). */
|
|
25
|
+
goodFor?: string[];
|
|
24
26
|
}
|
|
25
27
|
/**
|
|
26
28
|
* Heuristic classifier for a raw model id when we have no catalog tag (e.g. a
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* cli-probe — actually USE the user's own AI CLIs to learn what they can do.
|
|
3
|
+
*
|
|
4
|
+
* The launcher's option (1) registers installed CLIs, but a name alone tells us
|
|
5
|
+
* nothing about capability. Here we hand each authenticated CLI the
|
|
6
|
+
* CAPABILITY_PROBE_INSTRUCTION (via its headless mode, at $0 marginal cost —
|
|
7
|
+
* the user's existing subscription pays) and parse its self-reported tier /
|
|
8
|
+
* strengths / context / good-for. That report is what we store into the
|
|
9
|
+
* Headroom knowledge doc so the master can route work to the right model.
|
|
10
|
+
*
|
|
11
|
+
* The model call is injectable (`complete`) so this is unit-testable offline
|
|
12
|
+
* without spawning a real CLI.
|
|
13
|
+
*/
|
|
14
|
+
import { type CapabilityTier } from "./capabilities.js";
|
|
15
|
+
export interface CliCapability {
|
|
16
|
+
name: string;
|
|
17
|
+
maqProvider: string;
|
|
18
|
+
tier: CapabilityTier;
|
|
19
|
+
strengths: string[];
|
|
20
|
+
goodFor: string[];
|
|
21
|
+
contextTokens?: number;
|
|
22
|
+
vision?: boolean;
|
|
23
|
+
/** Whether the report came from the CLI itself vs a heuristic fallback. */
|
|
24
|
+
probed: boolean;
|
|
25
|
+
}
|
|
26
|
+
export type CompleteFn = (prompt: string) => Promise<string>;
|
|
27
|
+
/**
|
|
28
|
+
* Probe a single CLI. `complete` defaults to the cli:<name> provider; pass a
|
|
29
|
+
* stub in tests. Never throws — on any failure it returns a heuristic default
|
|
30
|
+
* so onboarding always proceeds.
|
|
31
|
+
*/
|
|
32
|
+
export declare function probeCliCapability(name: string, opts?: {
|
|
33
|
+
complete?: CompleteFn;
|
|
34
|
+
timeoutMs?: number;
|
|
35
|
+
}): Promise<CliCapability>;
|
|
36
|
+
/**
|
|
37
|
+
* Probe every authenticated CLI (or a provided list). Runs in parallel; each
|
|
38
|
+
* probe is independently best-effort.
|
|
39
|
+
*/
|
|
40
|
+
export declare function probeInstalledClis(opts?: {
|
|
41
|
+
names?: string[];
|
|
42
|
+
complete?: CompleteFn;
|
|
43
|
+
timeoutMs?: number;
|
|
44
|
+
}): Promise<CliCapability[]>;
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* cli-probe — actually USE the user's own AI CLIs to learn what they can do.
|
|
3
|
+
*
|
|
4
|
+
* The launcher's option (1) registers installed CLIs, but a name alone tells us
|
|
5
|
+
* nothing about capability. Here we hand each authenticated CLI the
|
|
6
|
+
* CAPABILITY_PROBE_INSTRUCTION (via its headless mode, at $0 marginal cost —
|
|
7
|
+
* the user's existing subscription pays) and parse its self-reported tier /
|
|
8
|
+
* strengths / context / good-for. That report is what we store into the
|
|
9
|
+
* Headroom knowledge doc so the master can route work to the right model.
|
|
10
|
+
*
|
|
11
|
+
* The model call is injectable (`complete`) so this is unit-testable offline
|
|
12
|
+
* without spawning a real CLI.
|
|
13
|
+
*/
|
|
14
|
+
import { getProvider } from "./model.js";
|
|
15
|
+
import { detectAgents } from "./registry.js";
|
|
16
|
+
import { CAPABILITY_PROBE_INSTRUCTION, parseCapabilityReply, classifyModel, } from "./capabilities.js";
|
|
17
|
+
/**
|
|
18
|
+
* Probe a single CLI. `complete` defaults to the cli:<name> provider; pass a
|
|
19
|
+
* stub in tests. Never throws — on any failure it returns a heuristic default
|
|
20
|
+
* so onboarding always proceeds.
|
|
21
|
+
*/
|
|
22
|
+
export async function probeCliCapability(name, opts = {}) {
|
|
23
|
+
const maqProvider = `cli:${name}`;
|
|
24
|
+
const fallback = {
|
|
25
|
+
name,
|
|
26
|
+
maqProvider,
|
|
27
|
+
tier: classifyModel(name),
|
|
28
|
+
strengths: [],
|
|
29
|
+
goodFor: ["code"],
|
|
30
|
+
probed: false,
|
|
31
|
+
};
|
|
32
|
+
const complete = opts.complete ??
|
|
33
|
+
(async (prompt) => {
|
|
34
|
+
const provider = getProvider(maqProvider, { strict: true });
|
|
35
|
+
const res = await provider.complete({
|
|
36
|
+
model: maqProvider,
|
|
37
|
+
messages: [{ role: "user", content: prompt }],
|
|
38
|
+
maxTokens: 300,
|
|
39
|
+
});
|
|
40
|
+
return res.text;
|
|
41
|
+
});
|
|
42
|
+
try {
|
|
43
|
+
const timeoutMs = opts.timeoutMs ?? 45000;
|
|
44
|
+
const reply = await withTimeout(complete(CAPABILITY_PROBE_INSTRUCTION), timeoutMs);
|
|
45
|
+
const parsed = parseCapabilityReply(reply);
|
|
46
|
+
if (!parsed)
|
|
47
|
+
return fallback;
|
|
48
|
+
return {
|
|
49
|
+
name,
|
|
50
|
+
maqProvider,
|
|
51
|
+
tier: parsed.tier,
|
|
52
|
+
strengths: parsed.strengths,
|
|
53
|
+
goodFor: parsed.goodFor.length ? parsed.goodFor : ["code"],
|
|
54
|
+
contextTokens: parsed.contextTokens,
|
|
55
|
+
vision: parsed.vision,
|
|
56
|
+
probed: true,
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
return fallback;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Probe every authenticated CLI (or a provided list). Runs in parallel; each
|
|
65
|
+
* probe is independently best-effort.
|
|
66
|
+
*/
|
|
67
|
+
export async function probeInstalledClis(opts = {}) {
|
|
68
|
+
const names = opts.names ??
|
|
69
|
+
detectAgents()
|
|
70
|
+
.filter((a) => a.installed && a.authenticated)
|
|
71
|
+
.map((a) => a.name);
|
|
72
|
+
return Promise.all(names.map((n) => probeCliCapability(n, { complete: opts.complete, timeoutMs: opts.timeoutMs })));
|
|
73
|
+
}
|
|
74
|
+
function withTimeout(p, ms) {
|
|
75
|
+
return new Promise((resolve, reject) => {
|
|
76
|
+
const timer = setTimeout(() => reject(new Error("capability probe timed out")), ms);
|
|
77
|
+
p.then((v) => {
|
|
78
|
+
clearTimeout(timer);
|
|
79
|
+
resolve(v);
|
|
80
|
+
}).catch((e) => {
|
|
81
|
+
clearTimeout(timer);
|
|
82
|
+
reject(e);
|
|
83
|
+
});
|
|
84
|
+
});
|
|
85
|
+
}
|
|
@@ -41,6 +41,7 @@ export const maqCommands = [
|
|
|
41
41
|
{ name: "memory", category: "memory", summary: "Recall memory store (injected into planning).", usage: "maq memory [store|recall|list]", needsInput: "query", args: [] },
|
|
42
42
|
{ name: "flow", category: "control", summary: "Scheduled agent sessions (run under the daemon).", usage: "maq flow [list|add|remove]", needsInput: "none", args: [] },
|
|
43
43
|
{ name: "audit", category: "control", summary: "Verify a run's hash-chained audit log.", usage: "maq audit verify <run-dir>", needsInput: "none", args: [] },
|
|
44
|
+
{ name: "security", category: "system", summary: "Enforced security rules (protected paths, egress allowlist, injection scanning) + recent findings.", usage: "maq security [report|rules|scan <path>]", needsInput: "none", args: [] },
|
|
44
45
|
];
|
|
45
46
|
/** Catalog of AI worker CLIs' own commands/flags (verified 2026-07-01). */
|
|
46
47
|
export const aiCliCatalog = [
|
|
@@ -31,6 +31,10 @@ export interface MaqConfig {
|
|
|
31
31
|
headroomModel: string;
|
|
32
32
|
/** True once the guided launcher has completed first-run setup. */
|
|
33
33
|
onboarded: boolean;
|
|
34
|
+
/** Project-specific additions to the protected-path denylist (base list is never removable). */
|
|
35
|
+
extraProtectedPaths: string[];
|
|
36
|
+
/** Additional hosts allowed for network egress (default allowlist is always applied on top). */
|
|
37
|
+
extraNetAllowlist: string[];
|
|
34
38
|
}
|
|
35
39
|
export declare const DEFAULT_CONFIG: MaqConfig;
|
|
36
40
|
export declare function configDir(): string;
|
|
@@ -39,3 +43,7 @@ export declare function loadConfig(): MaqConfig;
|
|
|
39
43
|
export declare function saveConfig(cfg: MaqConfig): void;
|
|
40
44
|
/** Set a single top-level scalar key by string, coercing to the existing type. */
|
|
41
45
|
export declare function setConfigKey(key: string, value: string): MaqConfig;
|
|
46
|
+
/** Append a value to an array-valued config key (e.g. security allowlists). */
|
|
47
|
+
export declare function addConfigListItem(key: string, value: string): MaqConfig;
|
|
48
|
+
/** Remove a value from an array-valued config key. */
|
|
49
|
+
export declare function removeConfigListItem(key: string, value: string): MaqConfig;
|
|
@@ -19,6 +19,8 @@ export const DEFAULT_CONFIG = {
|
|
|
19
19
|
executionMode: "loop",
|
|
20
20
|
headroomModel: "",
|
|
21
21
|
onboarded: false,
|
|
22
|
+
extraProtectedPaths: [],
|
|
23
|
+
extraNetAllowlist: [],
|
|
22
24
|
};
|
|
23
25
|
export function configDir() {
|
|
24
26
|
return process.env.MAQ_CONFIG_DIR ?? join(homedir(), ".maqcli");
|
|
@@ -57,9 +59,34 @@ export function setConfigKey(key, value) {
|
|
|
57
59
|
if (typeof current === "boolean")
|
|
58
60
|
coerced = value === "true";
|
|
59
61
|
if (typeof current === "object") {
|
|
60
|
-
throw new Error(`Cannot set object key '${key}' from the CLI`);
|
|
62
|
+
throw new Error(`Cannot set object key '${key}' from the CLI; use addConfigListItem/removeConfigListItem`);
|
|
61
63
|
}
|
|
62
64
|
cfg[key] = coerced;
|
|
63
65
|
saveConfig(cfg);
|
|
64
66
|
return cfg;
|
|
65
67
|
}
|
|
68
|
+
/** Array-valued config keys that may be extended/trimmed from the CLI. */
|
|
69
|
+
const LIST_KEYS = new Set(["extraProtectedPaths", "extraNetAllowlist"]);
|
|
70
|
+
/** Append a value to an array-valued config key (e.g. security allowlists). */
|
|
71
|
+
export function addConfigListItem(key, value) {
|
|
72
|
+
if (!LIST_KEYS.has(key))
|
|
73
|
+
throw new Error(`'${key}' is not a list config key`);
|
|
74
|
+
const cfg = loadConfig();
|
|
75
|
+
const list = cfg[key];
|
|
76
|
+
if (!list.includes(value))
|
|
77
|
+
list.push(value);
|
|
78
|
+
saveConfig(cfg);
|
|
79
|
+
return cfg;
|
|
80
|
+
}
|
|
81
|
+
/** Remove a value from an array-valued config key. */
|
|
82
|
+
export function removeConfigListItem(key, value) {
|
|
83
|
+
if (!LIST_KEYS.has(key))
|
|
84
|
+
throw new Error(`'${key}' is not a list config key`);
|
|
85
|
+
const cfg = loadConfig();
|
|
86
|
+
const list = cfg[key];
|
|
87
|
+
const idx = list.indexOf(value);
|
|
88
|
+
if (idx >= 0)
|
|
89
|
+
list.splice(idx, 1);
|
|
90
|
+
saveConfig(cfg);
|
|
91
|
+
return cfg;
|
|
92
|
+
}
|
package/dist/core/exec.d.ts
CHANGED
|
@@ -14,6 +14,15 @@ export interface ExecOptions {
|
|
|
14
14
|
maxBuffer?: number;
|
|
15
15
|
/** Abort signal; kills the child when aborted. */
|
|
16
16
|
signal?: AbortSignal;
|
|
17
|
+
/**
|
|
18
|
+
* Secret hygiene for the spawned process (NVIDIA: "secret injection, not
|
|
19
|
+
* inheritance"). Defaults to scrubbing secret-shaped env vars from the
|
|
20
|
+
* inherited environment. Set to false to opt out (e.g. a trusted local
|
|
21
|
+
* tool that legitimately needs a key); pass `secretAllowlist` to re-admit
|
|
22
|
+
* specific names the task actually needs.
|
|
23
|
+
*/
|
|
24
|
+
scrubSecrets?: boolean;
|
|
25
|
+
secretAllowlist?: string[];
|
|
17
26
|
}
|
|
18
27
|
export interface ExecOutcome {
|
|
19
28
|
code: number | null;
|
package/dist/core/exec.js
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
* chokepoint for running worker CLIs and raw commands.
|
|
7
7
|
*/
|
|
8
8
|
import { spawn } from "node:child_process";
|
|
9
|
+
import { scrubEnv } from "./security.js";
|
|
9
10
|
/**
|
|
10
11
|
* Run a command safely. `cmd` is the binary, `args` are passed verbatim.
|
|
11
12
|
* Never pass a full command string here — that is what enables injection.
|
|
@@ -13,9 +14,11 @@ import { spawn } from "node:child_process";
|
|
|
13
14
|
export function execSafe(cmd, args = [], opts = {}) {
|
|
14
15
|
const maxBuffer = opts.maxBuffer ?? 10 * 1024 * 1024;
|
|
15
16
|
return new Promise((resolve) => {
|
|
17
|
+
const baseEnv = opts.env ?? process.env;
|
|
18
|
+
const env = opts.scrubSecrets === false ? baseEnv : scrubEnv(baseEnv, opts.secretAllowlist);
|
|
16
19
|
const child = spawn(cmd, args, {
|
|
17
20
|
cwd: opts.cwd ?? process.cwd(),
|
|
18
|
-
env
|
|
21
|
+
env,
|
|
19
22
|
shell: false,
|
|
20
23
|
windowsHide: true,
|
|
21
24
|
});
|
|
@@ -78,9 +81,11 @@ export function execSafe(cmd, args = [], opts = {}) {
|
|
|
78
81
|
export function execStream(cmd, args = [], opts = {}) {
|
|
79
82
|
const maxBuffer = opts.maxBuffer ?? 20 * 1024 * 1024;
|
|
80
83
|
return new Promise((resolve) => {
|
|
84
|
+
const baseEnv = opts.env ?? process.env;
|
|
85
|
+
const env = opts.scrubSecrets === false ? baseEnv : scrubEnv(baseEnv, opts.secretAllowlist);
|
|
81
86
|
const child = spawn(cmd, args, {
|
|
82
87
|
cwd: opts.cwd ?? process.cwd(),
|
|
83
|
-
env
|
|
88
|
+
env,
|
|
84
89
|
shell: false,
|
|
85
90
|
windowsHide: true,
|
|
86
91
|
});
|
package/dist/core/launcher.d.ts
CHANGED
|
@@ -31,6 +31,12 @@ export declare function browserOpenCommand(url: string, platform?: NodeJS.Platfo
|
|
|
31
31
|
export declare function openBrowser(url: string): void;
|
|
32
32
|
/** The Megalodon splash. `color=false` yields a plain-text version. */
|
|
33
33
|
export declare function megalodonSplash(color?: boolean): string;
|
|
34
|
+
/**
|
|
35
|
+
* Frames of the megalodon swimming in from the left (red fin, white body). The
|
|
36
|
+
* last frame is the settled splash. Used only on a TTY; tests use the pure
|
|
37
|
+
* megalodonSplash above.
|
|
38
|
+
*/
|
|
39
|
+
export declare function megalodonFrames(color?: boolean): string[];
|
|
34
40
|
export interface OnboardingChoices {
|
|
35
41
|
/** Registered worker/master models (already tiered). */
|
|
36
42
|
models: TieredModel[];
|
package/dist/core/launcher.js
CHANGED
|
@@ -21,18 +21,10 @@ import { spawn } from "node:child_process";
|
|
|
21
21
|
import { randomInt } from "node:crypto";
|
|
22
22
|
import { loadConfig, saveConfig } from "./config-store.js";
|
|
23
23
|
import { detectAgents } from "./registry.js";
|
|
24
|
-
import { PROVIDER_CATALOG, detectAvailableProviders, getCatalogProvider, } from "./providers-catalog.js";
|
|
24
|
+
import { PROVIDER_CATALOG, detectAvailableProviders, getCatalogProvider, providerGoodFor, } from "./providers-catalog.js";
|
|
25
25
|
import { classifyModel, pickEfficient } from "./capabilities.js";
|
|
26
26
|
import { buildKnowledge, saveKnowledge, roleForModel } from "./onboarding.js";
|
|
27
|
-
|
|
28
|
-
const CLI_MASTER_HINT = {
|
|
29
|
-
gemini: "cli:gemini",
|
|
30
|
-
"claude-code": "cli:claude-code",
|
|
31
|
-
codex: "cli:codex",
|
|
32
|
-
opencode: "cli:opencode",
|
|
33
|
-
"amazon-q": "cli:amazon-q",
|
|
34
|
-
aider: "cli:aider",
|
|
35
|
-
};
|
|
27
|
+
import { probeCliCapability } from "./cli-probe.js";
|
|
36
28
|
/* --------------------------- pure, testable ---------------------------- */
|
|
37
29
|
/** A user-facing 9-digit pairing/auth key (100000000–999999999). */
|
|
38
30
|
export function generateAuthKey() {
|
|
@@ -85,6 +77,45 @@ export function megalodonSplash(color = true) {
|
|
|
85
77
|
"",
|
|
86
78
|
].join("\n");
|
|
87
79
|
}
|
|
80
|
+
/**
|
|
81
|
+
* Frames of the megalodon swimming in from the left (red fin, white body). The
|
|
82
|
+
* last frame is the settled splash. Used only on a TTY; tests use the pure
|
|
83
|
+
* megalodonSplash above.
|
|
84
|
+
*/
|
|
85
|
+
export function megalodonFrames(color = true) {
|
|
86
|
+
const r = color ? RED : "";
|
|
87
|
+
const w = color ? WHITE : "";
|
|
88
|
+
const x = color ? RST : "";
|
|
89
|
+
const shark = (pad) => {
|
|
90
|
+
const p = " ".repeat(pad);
|
|
91
|
+
return [
|
|
92
|
+
"",
|
|
93
|
+
"",
|
|
94
|
+
`${p}${w} ,${x}`,
|
|
95
|
+
`${p}${w} ,'| ${r}▄▄${x}`,
|
|
96
|
+
`${p}${r}≈≈≈≈≈≈≈${w},'__|${r}████████▀${x}`,
|
|
97
|
+
`${p}${w} \`.|${r}████▀${x}`,
|
|
98
|
+
`${p}${w} \`.${r}▀${x}`,
|
|
99
|
+
"",
|
|
100
|
+
"",
|
|
101
|
+
].join("\n");
|
|
102
|
+
};
|
|
103
|
+
return [shark(0), shark(6), shark(14), shark(24), megalodonSplash(color)];
|
|
104
|
+
}
|
|
105
|
+
async function animateSplash(color) {
|
|
106
|
+
if (!process.stdout.isTTY) {
|
|
107
|
+
line(megalodonSplash(color));
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
const frames = megalodonFrames(color);
|
|
111
|
+
for (let i = 0; i < frames.length; i++) {
|
|
112
|
+
process.stdout.write("\x1b[2J\x1b[H"); // clear + home
|
|
113
|
+
process.stdout.write(frames[i]);
|
|
114
|
+
if (i < frames.length - 1)
|
|
115
|
+
await new Promise((res) => setTimeout(res, 110));
|
|
116
|
+
}
|
|
117
|
+
process.stdout.write("\n");
|
|
118
|
+
}
|
|
88
119
|
/**
|
|
89
120
|
* Persist an onboarding outcome: pick the efficient model if none was chosen,
|
|
90
121
|
* write config (provider/model/tiers/permission/onboarded) and the Headroom
|
|
@@ -121,7 +152,7 @@ export function applyOnboarding(choices) {
|
|
|
121
152
|
: m.tier === "heavy"
|
|
122
153
|
? "reviewer"
|
|
123
154
|
: "worker";
|
|
124
|
-
return roleForModel(m, role, choices.source);
|
|
155
|
+
return roleForModel(m, role, choices.source, m.goodFor ?? []);
|
|
125
156
|
});
|
|
126
157
|
const knowledge = buildKnowledge({
|
|
127
158
|
providers,
|
|
@@ -144,7 +175,7 @@ function line(s = "") {
|
|
|
144
175
|
* (piped stdin), it prints guidance and returns without blocking.
|
|
145
176
|
*/
|
|
146
177
|
export async function runLauncher(cwd) {
|
|
147
|
-
|
|
178
|
+
await animateSplash(useColor());
|
|
148
179
|
if (!process.stdin.isTTY) {
|
|
149
180
|
line("maq: guided setup needs an interactive terminal.");
|
|
150
181
|
line(" • run `maq start` in a real terminal, or");
|
|
@@ -242,15 +273,22 @@ async function registerClis(rl) {
|
|
|
242
273
|
line("or pick option 3 (API providers). Continuing with none for now.");
|
|
243
274
|
return { models: [], source: "cli" };
|
|
244
275
|
}
|
|
245
|
-
line("\nFound these authenticated CLIs (
|
|
276
|
+
line("\nFound these authenticated CLIs. Asking each one to self-report its capabilities (uses your own subscription, $0)…");
|
|
246
277
|
const models = [];
|
|
247
278
|
for (const a of ready) {
|
|
248
|
-
const
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
279
|
+
const cap = await probeCliCapability(a.name, { timeoutMs: 45000 });
|
|
280
|
+
models.push({
|
|
281
|
+
id: cap.maqProvider,
|
|
282
|
+
provider: a.name,
|
|
283
|
+
maqProvider: cap.maqProvider,
|
|
284
|
+
tier: cap.tier,
|
|
285
|
+
vision: cap.vision,
|
|
286
|
+
goodFor: cap.goodFor,
|
|
287
|
+
});
|
|
288
|
+
const how = cap.probed ? `self-reported ${cap.tier}` : `${cap.tier} (probe unavailable)`;
|
|
289
|
+
const gf = cap.goodFor.length ? ` good for: ${cap.goodFor.join(", ")}` : "";
|
|
290
|
+
line(` • ${a.name.padEnd(12)} → ${cap.maqProvider} [${how}]${gf}`);
|
|
252
291
|
}
|
|
253
|
-
line(`\n(To register each CLI's own model list, MAQ will ask it "/models" on first use.)`);
|
|
254
292
|
return { models, source: "cli" };
|
|
255
293
|
}
|
|
256
294
|
async function registerSingleApi(rl) {
|
|
@@ -269,7 +307,7 @@ async function registerSingleApi(rl) {
|
|
|
269
307
|
const model = chosen.models[mpick - 1] ?? chosen.models[0];
|
|
270
308
|
line("\nNote: a single model can't fan out — parallel/safe modes are disabled until you add more.");
|
|
271
309
|
return {
|
|
272
|
-
models: [{ id: model.id, provider: chosen.id, maqProvider: chosen.maqProvider, tier: model.tier }],
|
|
310
|
+
models: [{ id: model.id, provider: chosen.id, maqProvider: chosen.maqProvider, tier: model.tier, goodFor: providerGoodFor(chosen.id) }],
|
|
273
311
|
source: "api",
|
|
274
312
|
};
|
|
275
313
|
}
|
|
@@ -292,6 +330,9 @@ async function registerMultiApi(rl) {
|
|
|
292
330
|
provider: d.provider.id,
|
|
293
331
|
maqProvider: d.provider.maqProvider,
|
|
294
332
|
tier: m.tier ?? classifyModel(m.id),
|
|
333
|
+
vision: m.vision,
|
|
334
|
+
longContext: m.longContext,
|
|
335
|
+
goodFor: providerGoodFor(d.provider.id),
|
|
295
336
|
})));
|
|
296
337
|
line(`\nRegistered ${models.length} models across ${active.length} active provider(s).`);
|
|
297
338
|
return { models, source: "api" };
|
|
@@ -312,7 +353,7 @@ async function connectMobile(rl) {
|
|
|
312
353
|
async function launchUi(authKey) {
|
|
313
354
|
// Reuse the daemon; open its landing page. Import lazily to avoid a cycle.
|
|
314
355
|
const { createDaemon } = await import("../server/daemon.js");
|
|
315
|
-
const daemon = createDaemon({ token: authKey, version: "0.
|
|
356
|
+
const daemon = createDaemon({ token: authKey, version: "0.6.0" });
|
|
316
357
|
try {
|
|
317
358
|
const { host, port } = await daemon.listen();
|
|
318
359
|
const url = `http://${host}:${port}/`;
|
|
@@ -70,6 +70,11 @@ export interface OrchestrationOptions {
|
|
|
70
70
|
onEvent?: (e: MaqEvent) => void;
|
|
71
71
|
signal?: AbortSignal;
|
|
72
72
|
checkpoint?: () => Promise<void>;
|
|
73
|
+
/**
|
|
74
|
+
* Permission gate consulted before MAJOR steps (moderate mode). Resolves
|
|
75
|
+
* true to proceed, false to hold. When absent, everything proceeds.
|
|
76
|
+
*/
|
|
77
|
+
requestPermission?: (action: string, detail: string, risk: "low" | "major" | "destructive") => Promise<boolean>;
|
|
73
78
|
/** Override any collaborator (tests inject deterministic ones). */
|
|
74
79
|
deps?: Partial<OrchestratorDeps>;
|
|
75
80
|
}
|
|
@@ -209,6 +209,14 @@ async function engineSafe(goal, deps, opts, emit) {
|
|
|
209
209
|
// 2. MERGE the parts via a (single) integration step on a strong model.
|
|
210
210
|
await opts.checkpoint?.();
|
|
211
211
|
emit(makeEvent("phase.started", { phase: "safe-merge", mode: "safe" }));
|
|
212
|
+
// Merging integrates everyone's work — a MAJOR action, so it passes through
|
|
213
|
+
// the permission gate (moderate mode may hold it for approval).
|
|
214
|
+
const allowMerge = opts.requestPermission ? await opts.requestPermission("merge", "integrate all sub-results into the final solution", "major") : true;
|
|
215
|
+
if (!allowMerge) {
|
|
216
|
+
emit(makeEvent("agent.event", { note: "merge held by permission policy (request-box)", phase: "safe-merge" }));
|
|
217
|
+
emit(makeEvent("phase.done", { phase: "safe-merge", held: true }));
|
|
218
|
+
return { goal, mode: "safe", rounds: 1, subtasks: partResults, verified: false, summary: "merge held pending approval" };
|
|
219
|
+
}
|
|
212
220
|
const merged = await deps.merge(goal, partResults, { onEvent: emit, signal: opts.signal });
|
|
213
221
|
emit(makeEvent("phase.done", { phase: "safe-merge", verified: merged.verified, status: merged.status }));
|
|
214
222
|
// 3. Final validation pass over everything.
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* permissions — the "request box" the spec describes.
|
|
3
|
+
*
|
|
4
|
+
* A single Headroom master controls everything, but it never performs work
|
|
5
|
+
* itself; its ants (workers) do. So permission is about gating the ants' major
|
|
6
|
+
* actions, not the master. Two postures:
|
|
7
|
+
*
|
|
8
|
+
* full — everything is allowed; the box stays empty.
|
|
9
|
+
* moderate — every MAJOR or DESTRUCTIVE action stops and is filed as a
|
|
10
|
+
* request. A goal-aware policy (the Headroom check) auto-approves
|
|
11
|
+
* actions that clearly serve the stated goal and holds the rest for
|
|
12
|
+
* an explicit approve/deny (by the master or a human via the UI).
|
|
13
|
+
*
|
|
14
|
+
* Requests are held in an in-memory box; each pending request exposes a promise
|
|
15
|
+
* (`await`) that resolves when it is decided. Low-risk actions never queue.
|
|
16
|
+
*/
|
|
17
|
+
export type PermissionMode = "full" | "moderate";
|
|
18
|
+
export type Risk = "low" | "major" | "destructive";
|
|
19
|
+
export type RequestStatus = "pending" | "approved" | "denied";
|
|
20
|
+
export interface PermissionRequest {
|
|
21
|
+
id: string;
|
|
22
|
+
action: string;
|
|
23
|
+
detail: string;
|
|
24
|
+
risk: Risk;
|
|
25
|
+
goal?: string;
|
|
26
|
+
status: RequestStatus;
|
|
27
|
+
reason: string;
|
|
28
|
+
ts: string;
|
|
29
|
+
decidedBy?: string;
|
|
30
|
+
}
|
|
31
|
+
/** Classify an action string into a risk level (deterministic, pattern-based). */
|
|
32
|
+
export declare function classifyRisk(action: string, detail?: string): Risk;
|
|
33
|
+
export interface Policy {
|
|
34
|
+
(req: Pick<PermissionRequest, "action" | "detail" | "risk" | "goal">): {
|
|
35
|
+
allow: boolean;
|
|
36
|
+
reason: string;
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* The Headroom check: allow when the action plainly serves the goal.
|
|
41
|
+
* - low risk → always allow
|
|
42
|
+
* - destructive → never auto-allow (must be approved explicitly)
|
|
43
|
+
* - major → allow only if it aligns with the goal (keyword overlap)
|
|
44
|
+
*/
|
|
45
|
+
export declare function goalAwarePolicy(req: Pick<PermissionRequest, "action" | "detail" | "risk" | "goal">): {
|
|
46
|
+
allow: boolean;
|
|
47
|
+
reason: string;
|
|
48
|
+
};
|
|
49
|
+
export declare class PermissionBroker {
|
|
50
|
+
private mode;
|
|
51
|
+
private policy;
|
|
52
|
+
private box;
|
|
53
|
+
private waiters;
|
|
54
|
+
constructor(mode?: PermissionMode, opts?: {
|
|
55
|
+
policy?: Policy;
|
|
56
|
+
});
|
|
57
|
+
getMode(): PermissionMode;
|
|
58
|
+
/**
|
|
59
|
+
* File a request. Returns the (possibly already-decided) request. In `full`
|
|
60
|
+
* mode everything is approved immediately; in `moderate` mode the policy runs
|
|
61
|
+
* and only holds what it cannot justify.
|
|
62
|
+
*/
|
|
63
|
+
request(action: string, detail: string, ctx?: {
|
|
64
|
+
risk?: Risk;
|
|
65
|
+
goal?: string;
|
|
66
|
+
}): PermissionRequest;
|
|
67
|
+
/** Resolve once the request is decided. Already-decided requests resolve now. */
|
|
68
|
+
await(id: string): Promise<boolean>;
|
|
69
|
+
/** Convenience: file + await in one call. */
|
|
70
|
+
gate(action: string, detail: string, ctx?: {
|
|
71
|
+
risk?: Risk;
|
|
72
|
+
goal?: string;
|
|
73
|
+
}): Promise<boolean>;
|
|
74
|
+
approve(id: string, by?: string): boolean;
|
|
75
|
+
deny(id: string, by?: string): boolean;
|
|
76
|
+
private decide;
|
|
77
|
+
pending(): PermissionRequest[];
|
|
78
|
+
list(): PermissionRequest[];
|
|
79
|
+
get(id: string): PermissionRequest | undefined;
|
|
80
|
+
}
|