@awak-app/simy-cli 0.5.20 → 0.5.22
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/README.md +17 -3
- package/package.json +5 -3
- package/src/agent.js +184 -75
- package/src/backend-executable.js +19 -5
- package/src/cli-contract.js +2 -1
- package/src/codex-plugins/simy/hooks/connection-reminder.mjs +1 -1
- package/src/console/app.js +1 -1
- package/src/desktop-host.js +30 -10
- package/src/desktop-process-lock.js +6 -3
- package/src/desktop-status.js +47 -21
- package/src/direct-executor.js +3 -0
- package/src/durable-local-task-steps-contract.js +6 -1
- package/src/durable-local-task-worker.js +5 -1
- package/src/execution-capability-contract.js +19 -22
- package/src/local-task.js +20 -1
- package/src/orchestrator/recovery.js +9 -0
- package/src/provider-stream.js +20 -2
- package/src/runner.js +4 -2
- package/src/sqm/agent-skill.js +8 -2
- package/src/sqm/claude-plugin-update.js +41 -0
- package/src/sqm/claude-plugin.js +122 -0
- package/src/sqm/provider-command.js +38 -0
- package/src/sqm/review-worker.js +87 -31
- package/src/sqm/workflow-onboarding.js +6 -2
- package/src/sqm/workflow-update.js +27 -8
- package/src/windows-bootstrap.js +6 -2
- package/src/windows-desktop-updater.js +39 -0
- package/src/windows-processes.js +37 -0
- package/src/windows-update.js +1 -1
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { readFile, realpath } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { CLAUDE_MARKETPLACE, CLAUDE_PLUGIN_SELECTOR } from "./claude-plugin.js";
|
|
4
|
+
|
|
5
|
+
export async function refreshClaudePlugin({ status, home, claudeHome, resolveExecutable, run }) {
|
|
6
|
+
if (!status.claude_plugin?.installed) return { outcome: "not_enabled" };
|
|
7
|
+
const executable = await resolveExecutable("claude");
|
|
8
|
+
if (!executable) return { outcome: "not_enabled" };
|
|
9
|
+
const options = { timeout: 10_000, maxBuffer: 1024 * 1024,
|
|
10
|
+
env: { ...process.env, HOME: home, CLAUDE_CONFIG_DIR: claudeHome } };
|
|
11
|
+
const list = async () => JSON.parse((await run(executable, ["plugin", "list", "--json"], options)).stdout);
|
|
12
|
+
const installed = (await list()).find((entry) => entry.id === CLAUDE_PLUGIN_SELECTOR && entry.scope === "user");
|
|
13
|
+
// Never install or re-enable a plugin on a background update, or modify a
|
|
14
|
+
// project/local/managed scope that SIMY did not install for this user.
|
|
15
|
+
if (!installed?.enabled) return { outcome: "not_enabled" };
|
|
16
|
+
const marketplaces = JSON.parse((await run(executable, ["plugin", "marketplace", "list", "--json"], options)).stdout);
|
|
17
|
+
const marketplace = marketplaces.find((entry) => entry.name === CLAUDE_MARKETPLACE);
|
|
18
|
+
if (marketplace?.source !== "directory" || !marketplace.path ||
|
|
19
|
+
await realpath(marketplace.path) !== await realpath(status.claude_plugin.marketplace_root)) {
|
|
20
|
+
throw new Error("Enabled SIMY Claude plugin points to a different marketplace; refresh skipped.");
|
|
21
|
+
}
|
|
22
|
+
const cacheCurrent = async (entry) => {
|
|
23
|
+
if (!entry?.installPath || entry.version !== status.claude_plugin.version) return false;
|
|
24
|
+
const cacheRoot = await realpath(path.join(claudeHome, "plugins/cache")).catch(() => null);
|
|
25
|
+
if (!cacheRoot) return false;
|
|
26
|
+
const installedPath = await realpath(entry.installPath).catch(() => null);
|
|
27
|
+
const relative = installedPath && path.relative(cacheRoot, installedPath);
|
|
28
|
+
if (!relative || relative === ".." || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) return false;
|
|
29
|
+
try {
|
|
30
|
+
return JSON.parse(await readFile(path.join(installedPath, ".claude-plugin/plugin.json"), "utf8")).version === status.claude_plugin.version;
|
|
31
|
+
} catch (error) { if (error.code === "ENOENT" || error instanceof SyntaxError) return false; throw error; }
|
|
32
|
+
};
|
|
33
|
+
if (await cacheCurrent(installed)) return { outcome: "current" };
|
|
34
|
+
await run(executable, ["plugin", "marketplace", "update", CLAUDE_MARKETPLACE], options);
|
|
35
|
+
await run(executable, ["plugin", "update", CLAUDE_PLUGIN_SELECTOR, "--scope", "user"], options);
|
|
36
|
+
const refreshed = (await list()).find((entry) => entry.id === CLAUDE_PLUGIN_SELECTOR && entry.scope === "user");
|
|
37
|
+
if (!refreshed?.enabled || !await cacheCurrent(refreshed)) {
|
|
38
|
+
throw new Error("Claude Code did not confirm the updated SIMY plugin cache; will retry.");
|
|
39
|
+
}
|
|
40
|
+
return { outcome: "updated", version: status.claude_plugin.version };
|
|
41
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { mkdir, readFile, readdir, rename, rm, lstat, writeFile } from "node:fs/promises";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
|
|
7
|
+
const SOURCE = fileURLToPath(new URL("../codex-plugins/simy/", import.meta.url));
|
|
8
|
+
export const CLAUDE_MARKETPLACE = "simy-local";
|
|
9
|
+
export const CLAUDE_PLUGIN_SELECTOR = `simy@${CLAUDE_MARKETPLACE}`;
|
|
10
|
+
const AUTHOR = "Specialist Doctors Inc.";
|
|
11
|
+
|
|
12
|
+
// Generate both providers from the maintained workflow source. Only provider
|
|
13
|
+
// terminology, manifest schema, and hook environment names differ.
|
|
14
|
+
export async function claudePluginFiles() {
|
|
15
|
+
const sourceManifest = JSON.parse(await readFile(path.join(SOURCE, ".codex-plugin/plugin.json"), "utf8"));
|
|
16
|
+
const files = new Map();
|
|
17
|
+
for (const entry of await readdir(path.join(SOURCE, "skills"), { withFileTypes: true })) {
|
|
18
|
+
if (!entry.isDirectory()) continue;
|
|
19
|
+
const relative = `skills/${entry.name}/SKILL.md`;
|
|
20
|
+
const content = await readFile(path.join(SOURCE, relative), "utf8");
|
|
21
|
+
files.set(relative, content.replaceAll("current Codex thread", "current Claude Code conversation"));
|
|
22
|
+
}
|
|
23
|
+
files.set("hooks/connection-reminder.mjs", await readFile(path.join(SOURCE, "hooks/connection-reminder.mjs"), "utf8"));
|
|
24
|
+
const hooks = JSON.parse(await readFile(path.join(SOURCE, "hooks/hooks.json"), "utf8"));
|
|
25
|
+
for (const groups of Object.values(hooks.hooks)) {
|
|
26
|
+
for (const group of groups) {
|
|
27
|
+
for (const hook of group.hooks) {
|
|
28
|
+
hook.command = hook.command.replace(/^node /, `${quoteRuntime(process.execPath)} `)
|
|
29
|
+
.replaceAll("process.env.PLUGIN_ROOT", "process.env.CLAUDE_PLUGIN_ROOT");
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
files.set("hooks/hooks.json", json(hooks));
|
|
34
|
+
const hash = createHash("sha256");
|
|
35
|
+
for (const [name, contents] of [...files].sort(([a], [b]) => a.localeCompare(b))) hash.update(name).update("\0").update(contents);
|
|
36
|
+
const manifest = {
|
|
37
|
+
name: "simy", version: `${sourceManifest.version.split("+")[0]}+claude.${hash.digest("hex").slice(0, 20)}`,
|
|
38
|
+
description: "SIMY quality checks, pull request review, and connection reminders for Claude Code.",
|
|
39
|
+
author: sourceManifest.author, repository: sourceManifest.repository,
|
|
40
|
+
license: sourceManifest.license, keywords: sourceManifest.keywords,
|
|
41
|
+
};
|
|
42
|
+
files.set(".claude-plugin/plugin.json", json(manifest));
|
|
43
|
+
return { files, manifest };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export async function inspectClaudePlugin({ home = homedir() } = {}) {
|
|
47
|
+
const root = marketplaceRoot(home);
|
|
48
|
+
const target = path.join(root, "plugins/simy");
|
|
49
|
+
const { files, manifest } = await claudePluginFiles();
|
|
50
|
+
let matches = true;
|
|
51
|
+
let exists = false;
|
|
52
|
+
for (const [name, contents] of files) {
|
|
53
|
+
const installed = await optionalRead(path.join(target, name));
|
|
54
|
+
if (name === ".claude-plugin/plugin.json") exists = installed !== null;
|
|
55
|
+
if (installed !== contents) matches = false;
|
|
56
|
+
}
|
|
57
|
+
const marketplacePath = path.join(root, ".claude-plugin/marketplace.json");
|
|
58
|
+
matches = matches && await optionalRead(marketplacePath) === json(marketplaceManifest());
|
|
59
|
+
return {
|
|
60
|
+
plugin_id: "simy", version: manifest.version, path: target,
|
|
61
|
+
marketplace_path: marketplacePath, marketplace_root: root, selector: CLAUDE_PLUGIN_SELECTOR,
|
|
62
|
+
installed: matches, state: matches ? "installed" : exists ? "outdated" : "missing",
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export async function installClaudePlugin({ home = homedir() } = {}) {
|
|
67
|
+
const root = marketplaceRoot(home);
|
|
68
|
+
const target = path.join(root, "plugins/simy");
|
|
69
|
+
// Never overwrite an unrelated user plugin, including a symlink.
|
|
70
|
+
let targetInfo = null;
|
|
71
|
+
try { targetInfo = await lstat(target); } catch (error) { if (error.code !== "ENOENT") throw error; }
|
|
72
|
+
if (targetInfo) {
|
|
73
|
+
if (!targetInfo.isDirectory()) throw new Error("SIMY Claude plugin target is not a managed directory.");
|
|
74
|
+
let manifest;
|
|
75
|
+
try { manifest = JSON.parse(await readFile(path.join(target, ".claude-plugin/plugin.json"), "utf8")); }
|
|
76
|
+
catch { throw new Error("SIMY Claude plugin target has no valid managed manifest."); }
|
|
77
|
+
if (manifest.name !== "simy" || manifest.author?.name !== AUTHOR) throw new Error("SIMY Claude plugin target is not managed by SIMY.");
|
|
78
|
+
}
|
|
79
|
+
const marketplacePath = path.join(root, ".claude-plugin/marketplace.json");
|
|
80
|
+
const existing = await optionalRead(marketplacePath);
|
|
81
|
+
if (existing !== null && existing !== json(marketplaceManifest())) {
|
|
82
|
+
throw new Error("SIMY Claude marketplace contains unmanaged changes; installation skipped.");
|
|
83
|
+
}
|
|
84
|
+
const { files } = await claudePluginFiles();
|
|
85
|
+
const stage = path.join(root, `plugins/.simy-${process.pid}.tmp`);
|
|
86
|
+
const backup = path.join(root, `plugins/.simy-${process.pid}.bak`);
|
|
87
|
+
await mkdir(path.dirname(stage), { recursive: true, mode: 0o700 });
|
|
88
|
+
await rm(stage, { recursive: true, force: true });
|
|
89
|
+
for (const [name, contents] of files) {
|
|
90
|
+
await mkdir(path.dirname(path.join(stage, name)), { recursive: true, mode: 0o700 });
|
|
91
|
+
await writeFile(path.join(stage, name), contents, { mode: 0o600 });
|
|
92
|
+
}
|
|
93
|
+
let backedUp = false;
|
|
94
|
+
try {
|
|
95
|
+
try { await rename(target, backup); backedUp = true; } catch (error) { if (error.code !== "ENOENT") throw error; }
|
|
96
|
+
await rename(stage, target);
|
|
97
|
+
await mkdir(path.dirname(marketplacePath), { recursive: true, mode: 0o700 });
|
|
98
|
+
await writeFile(`${marketplacePath}.tmp`, json(marketplaceManifest()), { mode: 0o600 });
|
|
99
|
+
await rename(`${marketplacePath}.tmp`, marketplacePath);
|
|
100
|
+
if (backedUp) await rm(backup, { recursive: true, force: true });
|
|
101
|
+
} catch (error) {
|
|
102
|
+
await rm(stage, { recursive: true, force: true });
|
|
103
|
+
if (backedUp) {
|
|
104
|
+
await rm(target, { recursive: true, force: true });
|
|
105
|
+
await rename(backup, target);
|
|
106
|
+
}
|
|
107
|
+
throw error;
|
|
108
|
+
}
|
|
109
|
+
return inspectClaudePlugin({ home });
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function marketplaceRoot(home) { return path.join(home, ".simy/integrations/claude-marketplace"); }
|
|
113
|
+
function marketplaceManifest() {
|
|
114
|
+
return { name: CLAUDE_MARKETPLACE, owner: { name: AUTHOR }, plugins: [{ name: "simy", source: "./plugins/simy" }] };
|
|
115
|
+
}
|
|
116
|
+
function json(value) { return `${JSON.stringify(value, null, 2)}\n`; }
|
|
117
|
+
async function optionalRead(file) {
|
|
118
|
+
try { return await readFile(file, "utf8"); } catch (error) { if (error.code === "ENOENT") return null; throw error; }
|
|
119
|
+
}
|
|
120
|
+
function quoteRuntime(value) {
|
|
121
|
+
return process.platform === "win32" ? `"${value.replaceAll('"', '""')}"` : `'${value.replaceAll("'", "'\\''")}'`;
|
|
122
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import spawn from "cross-spawn";
|
|
2
|
+
|
|
3
|
+
// cross-spawn resolves npm .cmd shims on Windows without interpolating command
|
|
4
|
+
// text. Keep updater commands bounded and preserve execFile-like test injection.
|
|
5
|
+
export function runProviderCommand(command, args, { env, timeout = 10_000, maxBuffer = 1024 * 1024 } = {}) {
|
|
6
|
+
return new Promise((resolve, reject) => {
|
|
7
|
+
const child = spawn(command, args, { env, windowsHide: true, stdio: ["ignore", "pipe", "pipe"] });
|
|
8
|
+
let stdout = "";
|
|
9
|
+
let stderr = "";
|
|
10
|
+
let settled = false;
|
|
11
|
+
const finish = (error) => {
|
|
12
|
+
if (settled) return;
|
|
13
|
+
settled = true;
|
|
14
|
+
clearTimeout(timer);
|
|
15
|
+
error ? reject(error) : resolve({ stdout, stderr });
|
|
16
|
+
};
|
|
17
|
+
const timer = setTimeout(() => {
|
|
18
|
+
child.kill("SIGTERM");
|
|
19
|
+
finish(new Error("Provider plugin command timed out."));
|
|
20
|
+
}, timeout);
|
|
21
|
+
timer.unref?.();
|
|
22
|
+
const capture = (stream, append) => {
|
|
23
|
+
stream.setEncoding("utf8");
|
|
24
|
+
stream.on("data", (chunk) => {
|
|
25
|
+
if (settled) return;
|
|
26
|
+
append(chunk);
|
|
27
|
+
if (Buffer.byteLength(stdout) + Buffer.byteLength(stderr) > maxBuffer) {
|
|
28
|
+
child.kill("SIGTERM");
|
|
29
|
+
finish(new Error("Provider plugin command exceeded the output limit."));
|
|
30
|
+
}
|
|
31
|
+
});
|
|
32
|
+
};
|
|
33
|
+
capture(child.stdout, (chunk) => { stdout += chunk; });
|
|
34
|
+
capture(child.stderr, (chunk) => { stderr += chunk; });
|
|
35
|
+
child.on("error", finish);
|
|
36
|
+
child.on("close", (code) => finish(code === 0 ? null : new Error(`Provider plugin command exited with code ${code}.`)));
|
|
37
|
+
});
|
|
38
|
+
}
|
package/src/sqm/review-worker.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import
|
|
1
|
+
import spawn from "cross-spawn";
|
|
2
2
|
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
|
3
3
|
import { tmpdir } from "node:os";
|
|
4
4
|
import { join } from "node:path";
|
|
@@ -79,7 +79,8 @@ export function createSqmLocalReviewWorker({
|
|
|
79
79
|
workerId = "sqm-local-review",
|
|
80
80
|
leaseSeconds = DEFAULT_LEASE_SECONDS,
|
|
81
81
|
heartbeatIntervalMs = Math.max(10_000, Math.floor(leaseSeconds * 500)),
|
|
82
|
-
|
|
82
|
+
getProvider = () => "codex",
|
|
83
|
+
runReview = (request, provider) => runStructuredProviderReview(provider, request),
|
|
83
84
|
beginWork = () => () => {},
|
|
84
85
|
onError = () => {},
|
|
85
86
|
} = {}) {
|
|
@@ -88,34 +89,35 @@ export function createSqmLocalReviewWorker({
|
|
|
88
89
|
let pollPromise = null;
|
|
89
90
|
let closed = false;
|
|
90
91
|
|
|
91
|
-
const leaseBody = (claim) => ({
|
|
92
|
+
const leaseBody = (claim, provider) => ({
|
|
93
|
+
executor_provider: provider,
|
|
92
94
|
worker_id: workerId,
|
|
93
95
|
lease_seconds: leaseSeconds,
|
|
94
96
|
job_id: claim.job_id,
|
|
95
97
|
lease_token: claim.lease_token,
|
|
96
98
|
});
|
|
97
99
|
|
|
98
|
-
const processClaim = async (claim) => {
|
|
100
|
+
const processClaim = async (claim, provider) => {
|
|
99
101
|
activeJobId = claim.job_id;
|
|
100
102
|
let heartbeatTimer = null;
|
|
101
103
|
const heartbeat = () => {
|
|
102
|
-
void api.request({ action: "heartbeat", ...leaseBody(claim) }).catch(onError);
|
|
104
|
+
void api.request({ action: "heartbeat", ...leaseBody(claim, provider) }).catch(onError);
|
|
103
105
|
};
|
|
104
106
|
heartbeatTimer = setInterval(heartbeat, heartbeatIntervalMs);
|
|
105
107
|
heartbeatTimer.unref?.();
|
|
106
108
|
try {
|
|
107
|
-
const analysis = await runReview(claim.request);
|
|
109
|
+
const analysis = await runReview(claim.request, provider);
|
|
108
110
|
const advanced = await api.request({
|
|
109
111
|
action: "advance",
|
|
110
|
-
...leaseBody(claim),
|
|
112
|
+
...leaseBody(claim, provider),
|
|
111
113
|
analysis_output: analysis.output,
|
|
112
114
|
executor_model: analysis.model,
|
|
113
115
|
});
|
|
114
116
|
if (advanced.completed) return advanced;
|
|
115
|
-
const adjudication = await runReview(advanced.request);
|
|
117
|
+
const adjudication = await runReview(advanced.request, provider);
|
|
116
118
|
return api.request({
|
|
117
119
|
action: "complete",
|
|
118
|
-
...leaseBody(claim),
|
|
120
|
+
...leaseBody(claim, provider),
|
|
119
121
|
analysis_output: analysis.output,
|
|
120
122
|
adjudication_output: adjudication.output,
|
|
121
123
|
executor_model: adjudication.model || analysis.model,
|
|
@@ -132,16 +134,20 @@ export function createSqmLocalReviewWorker({
|
|
|
132
134
|
},
|
|
133
135
|
poll() {
|
|
134
136
|
if (closed || pollPromise) return pollPromise || Promise.resolve(null);
|
|
137
|
+
const provider = getProvider();
|
|
138
|
+
if (provider == null) return Promise.resolve(null);
|
|
139
|
+
assertProvider(provider);
|
|
135
140
|
const releaseWork = beginWork?.();
|
|
136
141
|
if (!releaseWork) return Promise.resolve(null);
|
|
137
142
|
pollPromise = (async () => {
|
|
138
143
|
const claim = await api.request({
|
|
139
144
|
action: "claim",
|
|
145
|
+
executor_provider: provider,
|
|
140
146
|
worker_id: workerId,
|
|
141
147
|
lease_seconds: leaseSeconds,
|
|
142
148
|
});
|
|
143
149
|
if (!claim?.claimed) return null;
|
|
144
|
-
return processClaim(claim);
|
|
150
|
+
return processClaim(claim, provider);
|
|
145
151
|
})()
|
|
146
152
|
.catch((error) => {
|
|
147
153
|
onError(error);
|
|
@@ -160,19 +166,27 @@ export function createSqmLocalReviewWorker({
|
|
|
160
166
|
};
|
|
161
167
|
}
|
|
162
168
|
|
|
163
|
-
export
|
|
169
|
+
export function runStructuredCodexReview(request, options) {
|
|
170
|
+
return runStructuredProviderReview("codex", request, options);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export async function runStructuredProviderReview(
|
|
174
|
+
provider,
|
|
164
175
|
request,
|
|
165
176
|
{
|
|
166
177
|
spawnImpl = spawn,
|
|
167
178
|
inspectExecutor = inspectDesktopExecutor,
|
|
168
179
|
executionTimeoutMs = DEFAULT_EXECUTION_TIMEOUT_MS,
|
|
180
|
+
environment = process.env,
|
|
169
181
|
} = {},
|
|
170
182
|
) {
|
|
183
|
+
assertProvider(provider);
|
|
171
184
|
assertStructuredRequest(request);
|
|
172
|
-
const
|
|
185
|
+
const label = provider === "claude" ? "Claude Code" : "Codex";
|
|
186
|
+
const inspection = await inspectExecutor(provider);
|
|
173
187
|
if (!inspection?.compatible || !inspection.executable) {
|
|
174
|
-
throw new SqmLocalReviewError(
|
|
175
|
-
code:
|
|
188
|
+
throw new SqmLocalReviewError(`A compatible local ${label} executable is required.`, {
|
|
189
|
+
code: `${provider}_local_unavailable`,
|
|
176
190
|
});
|
|
177
191
|
}
|
|
178
192
|
const workDir = await mkdtemp(join(tmpdir(), "simy-sqm-review-"));
|
|
@@ -185,9 +199,15 @@ export async function runStructuredCodexReview(
|
|
|
185
199
|
"Return only JSON that strictly matches the provided output schema.",
|
|
186
200
|
request.prompt,
|
|
187
201
|
].join("\n\n");
|
|
188
|
-
await
|
|
202
|
+
const stdout = await spawnReview(
|
|
189
203
|
inspection.executable,
|
|
190
|
-
[
|
|
204
|
+
provider === "claude" ? [
|
|
205
|
+
"--print", "--output-format", "json",
|
|
206
|
+
"--json-schema", JSON.stringify(request.output_schema),
|
|
207
|
+
"--no-session-persistence", "--tools", "",
|
|
208
|
+
"--setting-sources", "", "--settings", '{"disableAllHooks":true}',
|
|
209
|
+
"--strict-mcp-config", "--mcp-config", '{"mcpServers":{}}',
|
|
210
|
+
] : [
|
|
191
211
|
"exec",
|
|
192
212
|
"--skip-git-repo-check",
|
|
193
213
|
"--sandbox",
|
|
@@ -202,63 +222,93 @@ export async function runStructuredCodexReview(
|
|
|
202
222
|
"-",
|
|
203
223
|
],
|
|
204
224
|
prompt,
|
|
205
|
-
{ spawnImpl, executionTimeoutMs },
|
|
225
|
+
{ spawnImpl, executionTimeoutMs, label, cwd: workDir, environment, provider },
|
|
206
226
|
);
|
|
207
|
-
const
|
|
227
|
+
const envelope = provider === "claude" ? JSON.parse(stdout) : null;
|
|
228
|
+
if (envelope?.is_error || (envelope && envelope.subtype !== "success")) {
|
|
229
|
+
throw new Error("Claude Code did not complete a structured review");
|
|
230
|
+
}
|
|
231
|
+
const output = provider === "claude"
|
|
232
|
+
? envelope?.structured_output
|
|
233
|
+
: JSON.parse(await readFile(outputPath, "utf8"));
|
|
208
234
|
if (!output || Array.isArray(output) || typeof output !== "object") {
|
|
209
|
-
throw new Error(
|
|
235
|
+
throw new Error(`${label} returned a non-object structured result`);
|
|
210
236
|
}
|
|
211
237
|
return {
|
|
212
238
|
output,
|
|
213
|
-
model:
|
|
239
|
+
model: `${provider}-local/${inspection.installed_version || "unknown"}`,
|
|
214
240
|
};
|
|
215
241
|
} catch (error) {
|
|
216
242
|
if (error instanceof SqmLocalReviewError) throw error;
|
|
217
|
-
throw new SqmLocalReviewError(`Local
|
|
218
|
-
code:
|
|
243
|
+
throw new SqmLocalReviewError(`Local ${label} review failed: ${errorMessage(error)}`, {
|
|
244
|
+
code: `${provider}_local_review_failed`,
|
|
219
245
|
});
|
|
220
246
|
} finally {
|
|
221
247
|
await rm(workDir, { recursive: true, force: true });
|
|
222
248
|
}
|
|
223
249
|
}
|
|
224
250
|
|
|
225
|
-
function
|
|
251
|
+
function spawnReview(command, args, prompt, { spawnImpl, executionTimeoutMs, label, cwd, environment, provider }) {
|
|
226
252
|
return new Promise((resolve, reject) => {
|
|
227
253
|
const child = spawnImpl(command, args, {
|
|
228
|
-
env:
|
|
229
|
-
|
|
254
|
+
env: safeReviewEnvironment(environment, provider),
|
|
255
|
+
cwd,
|
|
256
|
+
windowsHide: true,
|
|
257
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
230
258
|
});
|
|
259
|
+
let stdout = "";
|
|
231
260
|
let stderr = "";
|
|
232
261
|
let settled = false;
|
|
233
262
|
const timer = setTimeout(() => {
|
|
234
263
|
child.kill?.("SIGTERM");
|
|
235
|
-
finish(new Error(
|
|
264
|
+
finish(new Error(`${label} timed out after ${executionTimeoutMs}ms`));
|
|
236
265
|
}, executionTimeoutMs);
|
|
237
266
|
timer.unref?.();
|
|
238
267
|
const finish = (error = null) => {
|
|
239
268
|
if (settled) return;
|
|
240
269
|
settled = true;
|
|
241
270
|
clearTimeout(timer);
|
|
242
|
-
error ? reject(error) : resolve();
|
|
271
|
+
error ? reject(error) : resolve(stdout);
|
|
243
272
|
};
|
|
273
|
+
child.stdout?.setEncoding?.("utf8");
|
|
274
|
+
child.stdout?.on?.("data", (chunk) => {
|
|
275
|
+
if (settled) return;
|
|
276
|
+
stdout += chunk;
|
|
277
|
+
if (Buffer.byteLength(stdout, "utf8") > 2 * 1024 * 1024) {
|
|
278
|
+
child.kill?.("SIGTERM");
|
|
279
|
+
finish(new Error(`${label} structured output exceeded the size limit`));
|
|
280
|
+
}
|
|
281
|
+
});
|
|
282
|
+
child.stdin?.on?.("error", finish);
|
|
244
283
|
child.stderr?.setEncoding?.("utf8");
|
|
245
284
|
child.stderr?.on?.("data", (chunk) => {
|
|
246
285
|
stderr = `${stderr}${chunk}`.slice(-4000);
|
|
247
286
|
});
|
|
248
287
|
child.on("error", finish);
|
|
249
288
|
child.on("close", (code) => {
|
|
250
|
-
finish(code === 0 ? null : new Error(stderr.trim() ||
|
|
289
|
+
finish(code === 0 ? null : new Error(stderr.trim() || `${label} exited with code ${code}`));
|
|
251
290
|
});
|
|
252
291
|
child.stdin?.end(prompt);
|
|
253
292
|
});
|
|
254
293
|
}
|
|
255
294
|
|
|
256
|
-
function
|
|
295
|
+
function safeReviewEnvironment(environment, provider) {
|
|
257
296
|
const allowed = [
|
|
258
|
-
"HOME", "PATH", "CODEX_HOME", "
|
|
297
|
+
"HOME", "PATH", "CODEX_HOME", "CLAUDE_CONFIG_DIR",
|
|
298
|
+
"SystemRoot", "SYSTEMROOT", "USERPROFILE", "APPDATA", "LOCALAPPDATA", "TEMP", "TMP", "LANG", "LC_ALL", "LC_CTYPE", "TMPDIR",
|
|
259
299
|
"SSL_CERT_FILE", "SSL_CERT_DIR", "HTTPS_PROXY", "HTTP_PROXY", "ALL_PROXY", "NO_PROXY",
|
|
260
300
|
];
|
|
261
|
-
|
|
301
|
+
// Credentials stay in the local provider process. Never send them to the
|
|
302
|
+
// control plane, and do not pass the other provider's credentials.
|
|
303
|
+
allowed.push(...(provider === "claude" ? [
|
|
304
|
+
"ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN", "ANTHROPIC_BASE_URL", "CLAUDE_CODE_OAUTH_TOKEN",
|
|
305
|
+
"CLAUDE_CODE_USE_BEDROCK", "CLAUDE_CODE_USE_VERTEX", "CLAUDE_CODE_USE_FOUNDRY",
|
|
306
|
+
"AWS_PROFILE", "AWS_REGION", "AWS_DEFAULT_REGION", "AWS_ACCESS_KEY_ID", "AWS_SECRET_ACCESS_KEY", "AWS_SESSION_TOKEN",
|
|
307
|
+
"AWS_CONFIG_FILE", "AWS_SHARED_CREDENTIALS_FILE", "GOOGLE_APPLICATION_CREDENTIALS",
|
|
308
|
+
"ANTHROPIC_VERTEX_PROJECT_ID", "CLOUD_ML_REGION", "ANTHROPIC_FOUNDRY_API_KEY", "ANTHROPIC_FOUNDRY_RESOURCE",
|
|
309
|
+
] : ["OPENAI_API_KEY", "OPENAI_BASE_URL"]));
|
|
310
|
+
const keys = new Set(allowed.map((key) => key.toLowerCase()));
|
|
311
|
+
return Object.fromEntries(Object.entries(environment).filter(([key, value]) => value && keys.has(key.toLowerCase())));
|
|
262
312
|
}
|
|
263
313
|
|
|
264
314
|
function assertStructuredRequest(request) {
|
|
@@ -277,3 +327,9 @@ function machineCode(value) {
|
|
|
277
327
|
function errorMessage(error) {
|
|
278
328
|
return error instanceof Error ? error.message : String(error);
|
|
279
329
|
}
|
|
330
|
+
|
|
331
|
+
function assertProvider(provider) {
|
|
332
|
+
if (provider !== "codex" && provider !== "claude") {
|
|
333
|
+
throw new TypeError("Unsupported SQM local review provider");
|
|
334
|
+
}
|
|
335
|
+
}
|
|
@@ -58,8 +58,12 @@ export async function prepareDaemonWorkflows({
|
|
|
58
58
|
try {
|
|
59
59
|
const installed = await install();
|
|
60
60
|
logger(
|
|
61
|
-
"SIMY workflows installed.
|
|
61
|
+
"SIMY workflows installed. In Codex, restart, then find and install SIMY in Plugins. In Claude Code, add the SIMY marketplace and install simy@simy-local before using /simy:sqm-loop or /simy:pr-review.",
|
|
62
62
|
);
|
|
63
|
+
if (installed.claude_plugin?.marketplace_root) {
|
|
64
|
+
logger(`Claude Code: claude plugin marketplace add ${JSON.stringify(installed.claude_plugin.marketplace_root)}`);
|
|
65
|
+
logger("Then: claude plugin install simy@simy-local");
|
|
66
|
+
}
|
|
63
67
|
return { outcome: "installed", status: installed };
|
|
64
68
|
} catch (error) {
|
|
65
69
|
errorLogger(`SIMY workflow installation failed: ${message(error)}. The daemon will still start.`);
|
|
@@ -73,7 +77,7 @@ export async function confirmWorkflowInstallation({
|
|
|
73
77
|
} = {}) {
|
|
74
78
|
output.write(
|
|
75
79
|
[
|
|
76
|
-
"SIMY can install or update its Codex plugin
|
|
80
|
+
"SIMY can install or update its Codex and Claude Code plugin sources and opt-in SQM skills.",
|
|
77
81
|
"Existing unrelated plugins and hooks will be preserved.",
|
|
78
82
|
"",
|
|
79
83
|
].join("\n"),
|
|
@@ -1,13 +1,14 @@
|
|
|
1
|
-
import { execFile } from "node:child_process";
|
|
2
1
|
import { mkdir, readFile, realpath, rm, stat } from "node:fs/promises";
|
|
3
2
|
import { homedir } from "node:os";
|
|
4
3
|
import path from "node:path";
|
|
5
|
-
|
|
4
|
+
|
|
5
|
+
import { runProviderCommand } from "./provider-command.js";
|
|
6
|
+
import { refreshClaudePlugin } from "./claude-plugin-update.js";
|
|
6
7
|
|
|
7
8
|
import { resolveBackendExecutable } from "../backend-executable.js";
|
|
8
9
|
import { inspectSqmAgentSkill, installSqmAgentSkill, retireLegacySqmGate } from "./agent-skill.js";
|
|
9
10
|
|
|
10
|
-
|
|
11
|
+
|
|
11
12
|
const CHECK_INTERVAL_MS = 60 * 60 * 1000;
|
|
12
13
|
|
|
13
14
|
export function startWorkflowUpdates({ enabled, sync = syncInstalledWorkflows, intervalMs = CHECK_INTERVAL_MS,
|
|
@@ -32,8 +33,9 @@ export function startWorkflowUpdates({ enabled, sync = syncInstalledWorkflows, i
|
|
|
32
33
|
|
|
33
34
|
export async function syncInstalledWorkflows({ home = homedir(),
|
|
34
35
|
codexHome = process.env.CODEX_HOME || path.join(home, ".codex"),
|
|
36
|
+
claudeHome = process.env.CLAUDE_CONFIG_DIR || path.join(home, ".claude"),
|
|
35
37
|
inspect = inspectSqmAgentSkill, install = installSqmAgentSkill,
|
|
36
|
-
resolveExecutable = resolveBackendExecutable, run =
|
|
38
|
+
resolveExecutable = resolveBackendExecutable, run = runProviderCommand,
|
|
37
39
|
} = {}) {
|
|
38
40
|
// Multiple daemons may share the same installation. Serialize updates and recover
|
|
39
41
|
// an abandoned lock after more than the bounded Codex command timeouts.
|
|
@@ -52,7 +54,27 @@ export async function syncInstalledWorkflows({ home = homedir(),
|
|
|
52
54
|
const platforms = Object.entries(status.platforms)
|
|
53
55
|
.filter(([, entry]) => entry.state === "outdated").map(([platform]) => platform);
|
|
54
56
|
if (platforms.length) status = await install({ home, platforms });
|
|
55
|
-
|
|
57
|
+
const results = {};
|
|
58
|
+
const failures = [];
|
|
59
|
+
for (const [provider, refresh] of [
|
|
60
|
+
["codex", () => refreshCodexPlugin({ status, home, codexHome, resolveExecutable, run })],
|
|
61
|
+
["claude", () => refreshClaudePlugin({ status, home, claudeHome, resolveExecutable, run })],
|
|
62
|
+
]) {
|
|
63
|
+
try { results[provider] = await refresh(); }
|
|
64
|
+
catch (error) { failures.push(error); }
|
|
65
|
+
}
|
|
66
|
+
if (failures.length) throw new AggregateError(failures, failures.map((error) => error.message).join("; "));
|
|
67
|
+
if (results.claude.outcome === "updated") return { ...results.claude, providers: results };
|
|
68
|
+
if (results.codex.outcome !== "not_enabled") return results.codex;
|
|
69
|
+
return { outcome: platforms.length || migrated ? "updated" : results.claude.outcome === "current" ? "current" :
|
|
70
|
+
status.platforms.codex.installed || status.claude_plugin?.installed ? "not_enabled" : "unchanged" };
|
|
71
|
+
} finally {
|
|
72
|
+
await rm(lock, { recursive: true, force: true });
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async function refreshCodexPlugin({ status, home, codexHome, resolveExecutable, run }) {
|
|
77
|
+
if (!status.platforms.codex.installed) return { outcome: "not_enabled" };
|
|
56
78
|
|
|
57
79
|
const marketplace = JSON.parse(await readFile(status.codex_plugin.marketplace_path, "utf8"));
|
|
58
80
|
if (typeof marketplace.name !== "string" || !/^[A-Za-z0-9_-]+$/.test(marketplace.name)) {
|
|
@@ -85,7 +107,4 @@ export async function syncInstalledWorkflows({ home = homedir(),
|
|
|
85
107
|
throw new Error("Codex did not confirm the updated SIMY plugin cache; will retry.");
|
|
86
108
|
}
|
|
87
109
|
return { outcome: "updated", version: status.codex_plugin.version };
|
|
88
|
-
} finally {
|
|
89
|
-
await rm(lock, { recursive: true, force: true });
|
|
90
|
-
}
|
|
91
110
|
}
|
package/src/windows-bootstrap.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { fileURLToPath } from 'node:url';
|
|
2
2
|
import path from 'node:path';
|
|
3
|
+
import { existsSync } from 'node:fs';
|
|
3
4
|
import { spawn } from 'node:child_process';
|
|
4
5
|
import { maintainWindowsUpdate, selectedWindowsRoot } from './windows-update.js';
|
|
5
6
|
const installation = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
|
|
@@ -18,8 +19,11 @@ try {
|
|
|
18
19
|
if (updating) {
|
|
19
20
|
if (!process.exitCode) console.log(`SIMY Windows installation ready: ${root}`);
|
|
20
21
|
} else {
|
|
21
|
-
const
|
|
22
|
-
|
|
22
|
+
const desktop = argv[0] === '--desktop';
|
|
23
|
+
if (desktop && !existsSync(path.join(root, 'simy-desktop.exe'))) root = installation;
|
|
24
|
+
const child = spawn(path.join(root, desktop ? 'simy-desktop.exe' : 'runtime/node.exe'),
|
|
25
|
+
desktop ? ['--selected'] : [path.join(root, 'cli/src/index.js'), ...argv], {
|
|
26
|
+
stdio: 'inherit', env: { ...process.env, ...(desktop ? {} : { SIMY_AUTO_UPDATE: '0' }) },
|
|
23
27
|
});
|
|
24
28
|
// Windows console signals are already delivered to both processes.
|
|
25
29
|
process.on('SIGINT', () => {});
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { EventEmitter } from 'node:events';
|
|
2
|
+
import { WINDOWS_MANIFEST_URL, validateWindowsRelease, compareWindowsVersions } from './windows-update.js';
|
|
3
|
+
import { openAuthorizationUrl } from './browser.js';
|
|
4
|
+
|
|
5
|
+
// Windows selects verified immutable releases at startup. Never replace a live
|
|
6
|
+
// desktop/agent during a task; the UI offers the installer for immediate upgrades.
|
|
7
|
+
export function createWindowsDesktopUpdater({ version, fetchImpl = fetch, open = openAuthorizationUrl, disabled = false }) {
|
|
8
|
+
const events = new EventEmitter();
|
|
9
|
+
let timer, initial, release;
|
|
10
|
+
let state = { state: disabled ? 'disabled' : 'idle', current_version: version, automatic: false, install_mode: 'windows_installer',
|
|
11
|
+
message: 'Updates are applied when SIMY starts. Check now to download the latest installer.' };
|
|
12
|
+
const update = values => { state = { ...state, ...values }; events.emit('change'); };
|
|
13
|
+
let pending;
|
|
14
|
+
events.snapshot = () => structuredClone(state);
|
|
15
|
+
events.start = () => {
|
|
16
|
+
if (disabled || timer) return;
|
|
17
|
+
initial = setTimeout(() => void events.checkNow(), 15000); initial.unref?.();
|
|
18
|
+
timer = setInterval(() => void events.checkNow(), 3600000); timer.unref?.();
|
|
19
|
+
};
|
|
20
|
+
events.stop = () => { clearTimeout(initial); clearInterval(timer); timer = null; };
|
|
21
|
+
events.checkNow = () => pending ||= (async () => {
|
|
22
|
+
update({ state: 'checking', message: 'Checking for updates…' });
|
|
23
|
+
try {
|
|
24
|
+
const response = await fetchImpl(WINDOWS_MANIFEST_URL, { signal: AbortSignal.timeout(15000), redirect: 'error' });
|
|
25
|
+
if (!response.ok) throw new Error('Update service unavailable');
|
|
26
|
+
release = validateWindowsRelease(await response.json());
|
|
27
|
+
const newer = compareWindowsVersions(release.version, version) > 0;
|
|
28
|
+
update({ state: newer ? 'update_available' : 'up_to_date', target_version: newer ? release.version : null,
|
|
29
|
+
message: newer ? `SIMY ${release.version} is available. Install it after your tasks finish.` : 'SIMY is up to date.',
|
|
30
|
+
action: newer ? { url: release.artifacts[0].url } : null });
|
|
31
|
+
} catch { update({ state: 'failed', message: 'Could not check for updates. Try again.', action: null }); }
|
|
32
|
+
return events.snapshot();
|
|
33
|
+
})().finally(() => { pending = null; });
|
|
34
|
+
events.performAction = async () => { if (state.action?.url) await open(state.action.url); else await events.checkNow(); };
|
|
35
|
+
events.maybeApply = events.performAction;
|
|
36
|
+
events.beginWork = () => () => {};
|
|
37
|
+
events.acceptsNewWork = () => true;
|
|
38
|
+
return events;
|
|
39
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { execFile } from 'node:child_process';
|
|
2
|
+
import { promisify } from 'node:util';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
const run = promisify(execFile);
|
|
5
|
+
export async function windowsProcessStart(pid) {
|
|
6
|
+
if (!Number.isSafeInteger(pid) || pid <= 0) throw new Error('Invalid process');
|
|
7
|
+
const { stdout } = await powershell('(Get-Process -Id ([int]$env:SIMY_INSPECT_PID) -ErrorAction Stop).StartTime.ToUniversalTime().ToString("o")', { SIMY_INSPECT_PID: String(pid) });
|
|
8
|
+
return stdout.trim();
|
|
9
|
+
}
|
|
10
|
+
export async function windowsUserProcesses() {
|
|
11
|
+
const { stdout } = await powershell(`$ErrorActionPreference='Stop';$sid=[Security.Principal.WindowsIdentity]::GetCurrent().User.Value;$rows=@(Get-CimInstance Win32_Process -Filter "Name='node.exe' OR Name='nodejs.exe'" | ForEach-Object {$owner=Invoke-CimMethod -InputObject $_ -MethodName GetOwnerSid;if($owner.Sid -eq $sid){@{pid=$_.ProcessId;command=$_.CommandLine}}});ConvertTo-Json -InputObject $rows -Compress`);
|
|
12
|
+
return JSON.parse(stdout || '[]').map(row => ({ pid: Number(row.pid), args: splitWindowsArguments(row.command || '') }));
|
|
13
|
+
}
|
|
14
|
+
function powershell(script, env = {}) {
|
|
15
|
+
return run(path.join(process.env.SystemRoot || 'C:\\Windows', 'System32/WindowsPowerShell/v1.0/powershell.exe'),
|
|
16
|
+
['-NoProfile', '-NonInteractive', '-Command', script], { timeout: 15000, windowsHide: true, maxBuffer: 2*1024*1024, env: { ...process.env, ...env } });
|
|
17
|
+
}
|
|
18
|
+
// CommandLineToArgvW backslash/quote rules; never shell-unescape Windows paths.
|
|
19
|
+
export function splitWindowsArguments(command) {
|
|
20
|
+
const result = []; let i = 0;
|
|
21
|
+
while (i < command.length) {
|
|
22
|
+
while (/\s/.test(command[i] || '') && i < command.length) i++;
|
|
23
|
+
if (i === command.length) break;
|
|
24
|
+
let value = '', quoted = false;
|
|
25
|
+
while (i < command.length && (quoted || !/\s/.test(command[i]))) {
|
|
26
|
+
let slashes = 0;
|
|
27
|
+
while (command[i] === '\\') { slashes++; i++; }
|
|
28
|
+
if (command[i] === '"') {
|
|
29
|
+
value += '\\'.repeat(Math.floor(slashes / 2));
|
|
30
|
+
if (slashes % 2) value += '"'; else quoted = !quoted;
|
|
31
|
+
i++;
|
|
32
|
+
} else { value += '\\'.repeat(slashes); if (i < command.length) value += command[i++]; }
|
|
33
|
+
}
|
|
34
|
+
result.push(value);
|
|
35
|
+
}
|
|
36
|
+
return result;
|
|
37
|
+
}
|
package/src/windows-update.js
CHANGED
|
@@ -83,7 +83,7 @@ async function verifyWindowsCandidate(candidate, marker) {
|
|
|
83
83
|
if (marker?.signed) {
|
|
84
84
|
if (!/^[A-Fa-f0-9]{40}$/.test(marker.thumbprint || '')) throw new Error('Missing trusted signer');
|
|
85
85
|
// Paths are passed through environment values, never inserted into shell source.
|
|
86
|
-
await exec(powershell(), ['-NoProfile', '-NonInteractive', '-Command', "$ErrorActionPreference='Stop';foreach($file in @('simy.exe','runtime/node.exe')){$s=Get-AuthenticodeSignature (Join-Path $env:SIMY_CANDIDATE $file);if($s.Status -ne 'Valid'){throw 'Invalid signature'};if($file -
|
|
86
|
+
await exec(powershell(), ['-NoProfile', '-NonInteractive', '-Command', "$ErrorActionPreference='Stop';foreach($file in @('simy.exe','simy-desktop.exe','runtime/node.exe')){$s=Get-AuthenticodeSignature (Join-Path $env:SIMY_CANDIDATE $file);if($s.Status -ne 'Valid'){throw 'Invalid signature'};if($file -ne 'runtime/node.exe' -and $s.SignerCertificate.Thumbprint -ne $env:SIMY_SIGNER){throw 'Unexpected signer'}}"], { env: { ...process.env, SIMY_CANDIDATE: candidate, SIMY_SIGNER: marker.thumbprint }, timeout: 30_000, windowsHide: true });
|
|
87
87
|
}
|
|
88
88
|
const probeHome = path.join(candidate, '.update-probe');
|
|
89
89
|
try {
|