@awak-app/simy-cli 0.5.44 → 0.5.46
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 +16 -0
- package/package.json +2 -2
- package/src/agent.js +161 -10
- package/src/cli-contract.js +1 -1
- package/src/codex-plugins/simy/hooks/README.md +14 -0
- package/src/codex-plugins/simy/hooks/dev-work.mjs +270 -0
- package/src/codex-plugins/simy/hooks/hooks.json +9 -0
- package/src/desktop-host.js +24 -6
- package/src/desktop-sqm-settings.js +23 -0
- package/src/dev-work-events.js +103 -0
- package/src/sqm/claude-plugin.js +1 -0
- package/src/sqm/review-worker.js +30 -3
package/README.md
CHANGED
|
@@ -11,6 +11,22 @@ login status, tasks, and updates separately. A Codex login check does not prove
|
|
|
11
11
|
that a task can execute successfully; permissions and service availability are
|
|
12
12
|
checked when work starts.
|
|
13
13
|
|
|
14
|
+
The sidebar's **Organization** selector is global across Sessions, Tasks and SQM
|
|
15
|
+
and shows active memberships of the connected SIMY account. Switching opens browser authorization for the selected organization
|
|
16
|
+
and waits for active tasks to finish. Use the same account in the browser; cancel
|
|
17
|
+
an unfinished switch from the sidebar.
|
|
18
|
+
|
|
19
|
+
**Local SQM**, above the connection status, allows this Mac's Codex / Claude Code
|
|
20
|
+
to claim local SQM reviews. It defaults to off and is saved separately for each
|
|
21
|
+
Web environment, account, and organization. Turning it off stops new claims;
|
|
22
|
+
current analysis finishes and submits its result. The toggle does not grant SQM
|
|
23
|
+
subscription or organization permissions. First-time repository history discovery
|
|
24
|
+
still starts from the web workspace.
|
|
25
|
+
|
|
26
|
+
**SQM Tasks** shows local SQM reviews from the current agent session, separately
|
|
27
|
+
from ordinary **Tasks**. Up to 50 recent reviews are shown; changing organization
|
|
28
|
+
clears the local view. The web SQM workspace retains the authoritative history.
|
|
29
|
+
|
|
14
30
|
Closing the window keeps the agent running. Open the app again or choose
|
|
15
31
|
**Show SIMY** from its menu bar icon to return. **Quit SIMY** asks before
|
|
16
32
|
interrupting active tasks. The normal `simy` command remains available.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@awak-app/simy-cli",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.46",
|
|
4
4
|
"description": "Local SIMY executor for non-coding Local Tasks and Agentic Loop runs.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -42,7 +42,7 @@
|
|
|
42
42
|
"check:workflow-observation-sender": "node --check ./src/workflow-observation-sender.js",
|
|
43
43
|
"check:sqm": "node --check ./src/sqm/canonical.js && node --check ./src/sqm/validation.js && node --check ./src/sqm/reliability.js && node --check ./src/sqm/repository.js && node --check ./src/sqm/checkers.js && node --check ./src/sqm/bundle-store.js && node --check ./src/sqm/proof.js && node --check ./src/sqm/evidence-client.js && node --check ./src/sqm/session.js && node --check ./src/sqm/index.js && node --check ./src/sqm/command.js && node --check ./src/sqm/agent-skill.js && node --check ./src/sqm/workflow-onboarding.js && node --check ./src/sqm/workflow-update.js && node --check ./src/sqm/review-worker.js && node --check ./scripts/sqm-complete-loop-e2e.js && node --check ./scripts/sqm-local-review-e2e.js",
|
|
44
44
|
"check:real-provider-syntax": "node --check ./scripts/real-codex-probe.js && node --check ./scripts/real-claude-probe.js && node --check ./scripts/real-provider-console-fixture.js && node --check ./scripts/real-provider-console-e2e.js && node --check ./scripts/claude-parity-runtime-e2e.js && node --check ./scripts/claude-plugin-runtime-e2e.js",
|
|
45
|
-
"check:syntax": "node --check ./src/index.js && node --check ./src/install-update.js && node --check ./src/agent.js && node --check ./src/connection-discovery.js && node --check ./src/codex-plugins/simy/hooks/connection-reminder.mjs && node --check ./src/browser.js && node --check ./src/web-api.js && node --check ./src/durable-local-task-worker.js && node --check ./src/durable-local-task-steps-contract.js && node --check ./src/bounded-local-task-subtasks-contract.js && node --check ./src/bounded-local-task-subtask-pool.js && node --check ./src/local-task-artifact-contract.js && node --check ./src/execution-capability-contract.js && node --check ./src/shutdown.js && node --check ./src/run-registry.js && node --check ./src/direct-executor.js && node --check ./src/execution-guardrail.js && node --check ./src/backend-executable.js && node --check ./src/provider-stream.js && node --check ./src/console/index.js && node --check ./src/console/app.js && node --check ./src/console/commands.js && node --check ./src/local-attachments.js && node --check ./src/repository-inventory.js && node --check ./src/session-store.js && node --check ./src/web-origin.js && node --check ./src/workspace-context.js && node --check ./src/orchestrator/index.js && node --check ./src/orchestrator/shared.js && node --check ./src/orchestrator/recovery.js && node --check ./src/orchestrator/risk.js && node --check ./src/orchestrator/contract.js && node --check ./src/orchestrator/completion-contract.js && node --check ./src/orchestrator/pr-preparation.js && node --check ./src/orchestrator/minimum-completion.js && node --check ./src/orchestrator/delivery/scripts/check-minimum-completion.mjs && node --check ./src/orchestrator/instruction.js && node --check ./src/orchestrator/problem-solving.js && node --check ./src/orchestrator/budget.js && node --check ./src/orchestrator/execution-io.js && node --check ./src/orchestrator/presentation.js && node --check ./src/orchestrator/result.js && node --check ./src/orchestrator/retry.js && node --check ./src/orchestrator/evidence.js && node --check ./src/orchestrator/independent-audit.js && node --check ./src/orchestrator/audit.js && node --check ./src/orchestrator/loop.js && node --check ./src/runner.js && node --check ./src/coding-run-store.js && node --check ./scripts/fixtures/completion-contract-fixture.js && node --check ./scripts/fixtures/fake-coding-backend.js && node --check ./scripts/e2e-terminal-evidence.js && node --check ./scripts/audit-gate-e2e.js && node --check ./scripts/completion-contract-e2e.js && node --check ./scripts/retry-circuit-e2e.js && node --check ./scripts/follow-up-charter-e2e.js && node --check ./scripts/task-routing-e2e.js && node --check ./scripts/executor-version-preflight-e2e.js && node --check ./scripts/blocked-recovery-e2e.js && node --check ./scripts/process-localization-e2e.js && node --check ./scripts/hypothesis-retries-e2e.js && node --check ./scripts/budget-enforcement-e2e.js && node --check ./scripts/local-task-default-workspace-e2e.js && node --check ./scripts/local-task-idempotency-e2e.js && node --check ./scripts/console-e2e-fixture.js && node --check ./scripts/console-e2e.js && node --check ./scripts/console-recovery-e2e-fixture.js && node --check ./scripts/console-recovery-e2e.js && node --check ./scripts/real-provider-console-fixture.js && node --check ./scripts/real-provider-console-e2e.js && node --check ./scripts/fullstack-cli-smoke.js && node --check ./scripts/check-package-contents.js && node --check ./src/codex-plugins/simy/hooks/delivery-loop.mjs && node --check ./src/delivery-hooks/command.js && node --check ./src/delivery-hooks/desktop.js && node --check ./src/delivery-hooks/supervisor.js && node --check ./src/session-supervision/automatic-continuation.js",
|
|
45
|
+
"check:syntax": "node --check ./src/index.js && node --check ./src/install-update.js && node --check ./src/agent.js && node --check ./src/connection-discovery.js && node --check ./src/codex-plugins/simy/hooks/connection-reminder.mjs && node --check ./src/codex-plugins/simy/hooks/dev-work.mjs && node --check ./src/dev-work-events.js && node --check ./src/browser.js && node --check ./src/web-api.js && node --check ./src/durable-local-task-worker.js && node --check ./src/durable-local-task-steps-contract.js && node --check ./src/bounded-local-task-subtasks-contract.js && node --check ./src/bounded-local-task-subtask-pool.js && node --check ./src/local-task-artifact-contract.js && node --check ./src/execution-capability-contract.js && node --check ./src/shutdown.js && node --check ./src/run-registry.js && node --check ./src/direct-executor.js && node --check ./src/execution-guardrail.js && node --check ./src/backend-executable.js && node --check ./src/provider-stream.js && node --check ./src/console/index.js && node --check ./src/console/app.js && node --check ./src/console/commands.js && node --check ./src/local-attachments.js && node --check ./src/repository-inventory.js && node --check ./src/session-store.js && node --check ./src/web-origin.js && node --check ./src/workspace-context.js && node --check ./src/orchestrator/index.js && node --check ./src/orchestrator/shared.js && node --check ./src/orchestrator/recovery.js && node --check ./src/orchestrator/risk.js && node --check ./src/orchestrator/contract.js && node --check ./src/orchestrator/completion-contract.js && node --check ./src/orchestrator/pr-preparation.js && node --check ./src/orchestrator/minimum-completion.js && node --check ./src/orchestrator/delivery/scripts/check-minimum-completion.mjs && node --check ./src/orchestrator/instruction.js && node --check ./src/orchestrator/problem-solving.js && node --check ./src/orchestrator/budget.js && node --check ./src/orchestrator/execution-io.js && node --check ./src/orchestrator/presentation.js && node --check ./src/orchestrator/result.js && node --check ./src/orchestrator/retry.js && node --check ./src/orchestrator/evidence.js && node --check ./src/orchestrator/independent-audit.js && node --check ./src/orchestrator/audit.js && node --check ./src/orchestrator/loop.js && node --check ./src/runner.js && node --check ./src/coding-run-store.js && node --check ./scripts/fixtures/completion-contract-fixture.js && node --check ./scripts/fixtures/fake-coding-backend.js && node --check ./scripts/e2e-terminal-evidence.js && node --check ./scripts/audit-gate-e2e.js && node --check ./scripts/completion-contract-e2e.js && node --check ./scripts/retry-circuit-e2e.js && node --check ./scripts/follow-up-charter-e2e.js && node --check ./scripts/task-routing-e2e.js && node --check ./scripts/executor-version-preflight-e2e.js && node --check ./scripts/blocked-recovery-e2e.js && node --check ./scripts/process-localization-e2e.js && node --check ./scripts/hypothesis-retries-e2e.js && node --check ./scripts/budget-enforcement-e2e.js && node --check ./scripts/local-task-default-workspace-e2e.js && node --check ./scripts/local-task-idempotency-e2e.js && node --check ./scripts/console-e2e-fixture.js && node --check ./scripts/console-e2e.js && node --check ./scripts/console-recovery-e2e-fixture.js && node --check ./scripts/console-recovery-e2e.js && node --check ./scripts/real-provider-console-fixture.js && node --check ./scripts/real-provider-console-e2e.js && node --check ./scripts/fullstack-cli-smoke.js && node --check ./scripts/check-package-contents.js && node --check ./src/codex-plugins/simy/hooks/delivery-loop.mjs && node --check ./src/delivery-hooks/command.js && node --check ./src/delivery-hooks/desktop.js && node --check ./src/delivery-hooks/supervisor.js && node --check ./src/session-supervision/automatic-continuation.js",
|
|
46
46
|
"check:package": "node ./scripts/check-package-contents.js",
|
|
47
47
|
"build:macos-installer:dev": "bash packaging/build-macos-arm64.sh --unsigned-dev",
|
|
48
48
|
"build:macos-installer": "bash packaging/build-macos-arm64.sh --production",
|
package/src/agent.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { readSqmEnabled, writeSqmEnabled } from "./desktop-sqm-settings.js";
|
|
1
2
|
import { createServer } from "node:http";
|
|
2
3
|
import { randomBytes } from "node:crypto";
|
|
3
4
|
import { createReadStream } from "node:fs";
|
|
@@ -35,6 +36,12 @@ import {
|
|
|
35
36
|
writeSession,
|
|
36
37
|
} from "./session-store.js";
|
|
37
38
|
import { isRequestOriginAllowed, resolveWebOrigin } from "./web-origin.js";
|
|
39
|
+
import {
|
|
40
|
+
DEV_WORK_EVENT_MAX_BYTES,
|
|
41
|
+
DevWorkEventError,
|
|
42
|
+
normalizeDevWorkEvent,
|
|
43
|
+
sendDevWorkEvent,
|
|
44
|
+
} from "./dev-work-events.js";
|
|
38
45
|
import {
|
|
39
46
|
cleanupExpiredRuns,
|
|
40
47
|
cleanupRunAttachments,
|
|
@@ -156,6 +163,7 @@ export async function startAgent({
|
|
|
156
163
|
dependencies = {},
|
|
157
164
|
quiet = false,
|
|
158
165
|
} = {}) {
|
|
166
|
+
sessionRoot ??= defaultSessionRoot();
|
|
159
167
|
const apiOrigin = resolveWebOrigin(webOrigin);
|
|
160
168
|
const registry = new LocalRunRegistry();
|
|
161
169
|
const codingRunStateRoot = codingRunsRoot(sessionRoot);
|
|
@@ -172,7 +180,7 @@ export async function startAgent({
|
|
|
172
180
|
join(dirname(taskStorageRoot), "attachment-staging"),
|
|
173
181
|
});
|
|
174
182
|
await localTaskAttachmentStore.cleanupExpired?.();
|
|
175
|
-
|
|
183
|
+
let authNonce = randomBytes(16).toString("base64url");
|
|
176
184
|
const sqmAgentIntegration = dependencies.sqmAgentIntegration || {
|
|
177
185
|
inspect: () => inspectSqmAgentSkill({ home: dependencies.home }),
|
|
178
186
|
install: (options) => installSqmAgentSkill({ home: dependencies.home, ...options }),
|
|
@@ -182,6 +190,11 @@ export async function startAgent({
|
|
|
182
190
|
// as its device (see /v1/auth/complete), so keep it after it is cleared.
|
|
183
191
|
let reclaimableSession = null;
|
|
184
192
|
if (session && sessionRequiresWebAuthorization(session, apiOrigin)) session = null;
|
|
193
|
+
let sqmEnabled = await readSqmEnabled(sessionRoot, apiOrigin, session);
|
|
194
|
+
let organizationItems = [];
|
|
195
|
+
let organizationState = "unknown";
|
|
196
|
+
let organizationRefresh = null;
|
|
197
|
+
let pendingOrganization = null;
|
|
185
198
|
await cleanupExpiredRuns();
|
|
186
199
|
for (const task of await restoreLocalTasks({ root: taskStorageRoot })) {
|
|
187
200
|
localTaskRegistry.create(task);
|
|
@@ -211,9 +224,14 @@ export async function startAgent({
|
|
|
211
224
|
let availableCapabilities;
|
|
212
225
|
let providerStatuses = { codex: { state: "unknown" }, claude: { state: "unknown" } };
|
|
213
226
|
const refreshAvailableCapabilities = () => {
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
227
|
+
const features = contractSourceCapabilities.features;
|
|
228
|
+
availableCapabilities = withCliContract({
|
|
229
|
+
...contractSourceCapabilities,
|
|
230
|
+
...(features ? { features: { ...features, ...(Object.hasOwn(features, "sqm_local_review")
|
|
231
|
+
? { sqm_local_review: features.sqm_local_review === true && sqmEnabled && !pendingOrganization } : {}) } } : {}),
|
|
232
|
+
...(providerStatuses ? { provider_statuses: providerStatuses } : {}),
|
|
233
|
+
}, installMode, {
|
|
234
|
+
authenticated: isSessionValid(session, Date.now(), apiOrigin) && Boolean(session?.device_id),
|
|
217
235
|
});
|
|
218
236
|
return availableCapabilities;
|
|
219
237
|
};
|
|
@@ -376,8 +394,43 @@ export async function startAgent({
|
|
|
376
394
|
}
|
|
377
395
|
};
|
|
378
396
|
|
|
379
|
-
const
|
|
380
|
-
|
|
397
|
+
const organizationBusy = () => Boolean(sqmLocalReviewWorker?.snapshot?.().polling ||
|
|
398
|
+
durableLocalTaskWorker?.snapshot?.().polling || durableLocalTaskWorker?.snapshot?.().active_turn_id ||
|
|
399
|
+
desktopTaskStatus([registry, directRegistry, localTaskRegistry]).activeCount);
|
|
400
|
+
const refreshOrganizations = async () => {
|
|
401
|
+
if (!isSessionValid(session, Date.now(), apiOrigin)) return;
|
|
402
|
+
if (organizationRefresh) return organizationRefresh;
|
|
403
|
+
const requestedSession = session;
|
|
404
|
+
organizationState = "loading";
|
|
405
|
+
organizationRefresh = (async () => {
|
|
406
|
+
try {
|
|
407
|
+
const response = await (dependencies.fetch || globalThis.fetch)(webApiUrl("organizations", {
|
|
408
|
+
webOrigin: apiOrigin, apiBaseUrl: requestedSession.api_base_url,
|
|
409
|
+
}), { headers: webApiHeaders({ token: requestedSession.token }), signal: AbortSignal.timeout(10_000) });
|
|
410
|
+
if (!response.ok) throw new Error("Organization lookup unavailable.");
|
|
411
|
+
const payload = await response.json();
|
|
412
|
+
if (!Array.isArray(payload.organizations)) throw new Error("Invalid organization list.");
|
|
413
|
+
if (session !== requestedSession) return;
|
|
414
|
+
organizationItems = payload.organizations.filter(org => typeof org.id === "string" && typeof org.name === "string")
|
|
415
|
+
.slice(0, 100).map(org => ({ id: org.id, name: org.name.slice(0, 120) }));
|
|
416
|
+
organizationState = "ready";
|
|
417
|
+
} catch {
|
|
418
|
+
if (session === requestedSession) organizationState = "unavailable";
|
|
419
|
+
} finally { organizationRefresh = null; notifyDesktopStatusChange(); }
|
|
420
|
+
})();
|
|
421
|
+
return organizationRefresh;
|
|
422
|
+
};
|
|
423
|
+
const requestDesktopLogin = (organizationId = null) => {
|
|
424
|
+
if (organizationId) {
|
|
425
|
+
if (!isSessionValid(session, Date.now(), apiOrigin)) throw new Error("Sign in before switching organizations.");
|
|
426
|
+
if (organizationState !== "ready" || !organizationItems.some(org => org.id === organizationId)) throw new Error("Refresh the organization list before selecting an organization.");
|
|
427
|
+
if (organizationBusy()) throw Object.assign(new Error("Wait for running tasks to finish before switching organizations."), { code: "desktop_organization_busy" });
|
|
428
|
+
authNonce = randomBytes(16).toString("base64url");
|
|
429
|
+
pendingOrganization = { id: organizationId, accountId: session.account_id };
|
|
430
|
+
loginUrl = null;
|
|
431
|
+
refreshAvailableCapabilities();
|
|
432
|
+
notifyDesktopStatusChange();
|
|
433
|
+
} else if (isSessionValid(session, Date.now(), apiOrigin)) return null;
|
|
381
434
|
if (!loginUrl) {
|
|
382
435
|
loginUrl = new URL("/local-cli/connect", apiOrigin);
|
|
383
436
|
loginUrl.searchParams.set("port", String(port));
|
|
@@ -385,6 +438,7 @@ export async function startAgent({
|
|
|
385
438
|
loginUrl.searchParams.set("cli_version", CLI_VERSION);
|
|
386
439
|
loginUrl.searchParams.set("api_contract_version", String(CLI_API_CONTRACT_VERSION));
|
|
387
440
|
if (installMode) loginUrl.searchParams.set("install_mode", installMode);
|
|
441
|
+
if (organizationId) loginUrl.searchParams.set("org_id", organizationId);
|
|
388
442
|
}
|
|
389
443
|
return loginUrl.toString();
|
|
390
444
|
};
|
|
@@ -555,8 +609,8 @@ export async function startAgent({
|
|
|
555
609
|
} else {
|
|
556
610
|
await publishVerifiedProviders();
|
|
557
611
|
}
|
|
558
|
-
if (shuttingDown) return false;
|
|
559
|
-
const sqmResult = sqmLocalReviewWorker
|
|
612
|
+
if (shuttingDown || pendingOrganization) return false;
|
|
613
|
+
const sqmResult = sqmEnabled && sqmLocalReviewWorker
|
|
560
614
|
? await sqmLocalReviewWorker.poll()
|
|
561
615
|
: null;
|
|
562
616
|
if (shuttingDown) return false;
|
|
@@ -709,7 +763,8 @@ export async function startAgent({
|
|
|
709
763
|
api: sqmApi,
|
|
710
764
|
beginWork: () => acquireUpdateWork(updateManager),
|
|
711
765
|
runReview: dependencies.runSqmLocalReview,
|
|
712
|
-
|
|
766
|
+
onChange: notifyDesktopStatusChange,
|
|
767
|
+
getProvider: () => availableCapabilities?.features?.sqm_local_review !== true ? null : ["codex", "claude"].find(
|
|
713
768
|
(provider) => availableCapabilities?.backends?.[provider] === true &&
|
|
714
769
|
providerStatuses[provider]?.state === "ready",
|
|
715
770
|
) || null,
|
|
@@ -797,6 +852,53 @@ export async function startAgent({
|
|
|
797
852
|
json(res, 200, refreshAvailableCapabilities());
|
|
798
853
|
return;
|
|
799
854
|
}
|
|
855
|
+
if (url.pathname === "/v1/dev-work-events") {
|
|
856
|
+
// Local-only: the SIMY plugin hook reports Git state from this machine.
|
|
857
|
+
// A browser (including SIMY Web) always sends Origin, so any Origin is
|
|
858
|
+
// refused; the hook posts without one.
|
|
859
|
+
if (req.headers.origin !== undefined) {
|
|
860
|
+
json(res, 403, { error: "dev-work events are accepted only from local hooks" });
|
|
861
|
+
return;
|
|
862
|
+
}
|
|
863
|
+
if (req.method !== "POST") {
|
|
864
|
+
json(res, 405, { error: "method not allowed" });
|
|
865
|
+
return;
|
|
866
|
+
}
|
|
867
|
+
if (!String(req.headers["content-type"] || "").toLowerCase().startsWith("application/json")) {
|
|
868
|
+
json(res, 415, { error: "JSON body required" });
|
|
869
|
+
return;
|
|
870
|
+
}
|
|
871
|
+
let event;
|
|
872
|
+
try {
|
|
873
|
+
event = normalizeDevWorkEvent(await readBoundedJson(req, DEV_WORK_EVENT_MAX_BYTES));
|
|
874
|
+
} catch (error) {
|
|
875
|
+
json(res, error instanceof DevWorkEventError ? error.status : 400, {
|
|
876
|
+
error: "Invalid dev-work event.",
|
|
877
|
+
});
|
|
878
|
+
return;
|
|
879
|
+
}
|
|
880
|
+
if (!isSessionValid(session, Date.now(), apiOrigin) || !session?.device_id) {
|
|
881
|
+
json(res, 401, { error: "Sign in to SIMY CLI to report dev-work." });
|
|
882
|
+
return;
|
|
883
|
+
}
|
|
884
|
+
try {
|
|
885
|
+
json(
|
|
886
|
+
res,
|
|
887
|
+
200,
|
|
888
|
+
await sendDevWorkEvent({
|
|
889
|
+
event,
|
|
890
|
+
session,
|
|
891
|
+
apiOrigin,
|
|
892
|
+
fetchImpl: dependencies.fetch || globalThis.fetch,
|
|
893
|
+
}),
|
|
894
|
+
);
|
|
895
|
+
} catch (error) {
|
|
896
|
+
json(res, error instanceof DevWorkEventError ? error.status : 502, {
|
|
897
|
+
error: error instanceof DevWorkEventError ? error.message : "dev-work event failed",
|
|
898
|
+
});
|
|
899
|
+
}
|
|
900
|
+
return;
|
|
901
|
+
}
|
|
800
902
|
if (url.pathname === "/v1/integrations/sqm-agent") {
|
|
801
903
|
if (!isSessionValid(session, Date.now(), apiOrigin) || !session?.device_id) {
|
|
802
904
|
json(res, 401, { error: "Reconnect SIMY CLI before managing the SQM agent skill." });
|
|
@@ -943,6 +1045,14 @@ export async function startAgent({
|
|
|
943
1045
|
});
|
|
944
1046
|
return;
|
|
945
1047
|
}
|
|
1048
|
+
const nextOrganization = body.organization_id ?? body.org_id;
|
|
1049
|
+
const nextAccount = body.account_id ?? body.auth_user_id;
|
|
1050
|
+
const changingScope = session && (session.organization_id !== nextOrganization || session.account_id !== nextAccount);
|
|
1051
|
+
if ((changingScope && organizationBusy()) || (pendingOrganization &&
|
|
1052
|
+
(pendingOrganization.id !== nextOrganization || pendingOrganization.accountId !== nextAccount))) {
|
|
1053
|
+
json(res, 409, { error: "Organization authorization does not match, or tasks are still running." });
|
|
1054
|
+
return;
|
|
1055
|
+
}
|
|
946
1056
|
const previousSession = session ?? reclaimableSession;
|
|
947
1057
|
reclaimableSession = null;
|
|
948
1058
|
session = {
|
|
@@ -971,6 +1081,8 @@ export async function startAgent({
|
|
|
971
1081
|
session.device_id &&
|
|
972
1082
|
previousSession.device_id !== session.device_id &&
|
|
973
1083
|
previousSession.api_origin &&
|
|
1084
|
+
(previousSession.account_id ?? null) === (session.account_id ?? null) &&
|
|
1085
|
+
(previousSession.organization_id ?? null) === (session.organization_id ?? null) &&
|
|
974
1086
|
previousApiBaseUrl(previousSession) === session.api_base_url
|
|
975
1087
|
) {
|
|
976
1088
|
try {
|
|
@@ -994,6 +1106,14 @@ export async function startAgent({
|
|
|
994
1106
|
}
|
|
995
1107
|
}
|
|
996
1108
|
await writeSession(apiOrigin, session, sessionRoot);
|
|
1109
|
+
authNonce = randomBytes(16).toString("base64url");
|
|
1110
|
+
pendingOrganization = null;
|
|
1111
|
+
sqmEnabled = await readSqmEnabled(sessionRoot, apiOrigin, session);
|
|
1112
|
+
if (changingScope) sqmLocalReviewWorker?.resetHistory?.();
|
|
1113
|
+
organizationState = "unknown";
|
|
1114
|
+
organizationItems = [];
|
|
1115
|
+
void refreshOrganizations();
|
|
1116
|
+
refreshAvailableCapabilities();
|
|
997
1117
|
startHeartbeatTimer();
|
|
998
1118
|
loginUrl = null;
|
|
999
1119
|
registry.emit("change", registry.list());
|
|
@@ -2008,6 +2128,7 @@ export async function startAgent({
|
|
|
2008
2128
|
});
|
|
2009
2129
|
const getDesktopStatus = async () => {
|
|
2010
2130
|
const auth = desktopAuthStatus(session, { webOrigin: apiOrigin });
|
|
2131
|
+
if (auth.state === "signed_in" && organizationState === "unknown") void refreshOrganizations();
|
|
2011
2132
|
if (deferStartupSynchronization && !shuttingDown) {
|
|
2012
2133
|
// Cached checks return immediately; expired checks refresh in the
|
|
2013
2134
|
// background without making the desktop frame wait for subprocesses.
|
|
@@ -2024,6 +2145,13 @@ export async function startAgent({
|
|
|
2024
2145
|
auth,
|
|
2025
2146
|
connection: connectionStatus.snapshot({ authenticated: auth.state === "signed_in" }),
|
|
2026
2147
|
tasks: desktopTaskStatus([registry, directRegistry, localTaskRegistry]),
|
|
2148
|
+
organizations: { state: organizationState, items: organizationItems, selectedId: session?.organization_id ?? null,
|
|
2149
|
+
switching: Boolean(pendingOrganization), canSwitch: auth.state === "signed_in" && !organizationBusy() },
|
|
2150
|
+
sqm: { enabled: sqmEnabled, canEnable: auth.state === "signed_in" && Boolean(session?.account_id && session?.organization_id) && !pendingOrganization,
|
|
2151
|
+
activeJobId: sqmLocalReviewWorker?.snapshot?.().active_job_id ?? null,
|
|
2152
|
+
issue: sqmLocalReviewWorker?.snapshot?.().issue ?? null,
|
|
2153
|
+
providerReady: [codex, claude].some(provider => provider?.state === "ready") },
|
|
2154
|
+
sqmTasks: sqmLocalReviewWorker?.desktopTasks?.() ?? { activeCount: 0, items: [] },
|
|
2027
2155
|
update: updateManager?.snapshot?.() ?? null,
|
|
2028
2156
|
codex,
|
|
2029
2157
|
claude,
|
|
@@ -2045,8 +2173,20 @@ export async function startAgent({
|
|
|
2045
2173
|
return requestDesktopLogin();
|
|
2046
2174
|
},
|
|
2047
2175
|
requestDesktopLogin,
|
|
2176
|
+
refreshOrganizations,
|
|
2177
|
+
cancelOrganizationSelection() { authNonce = randomBytes(16).toString("base64url"); pendingOrganization = null; loginUrl = null; refreshAvailableCapabilities(); notifyDesktopStatusChange(); },
|
|
2178
|
+
async setSqmEnabled(enabled) {
|
|
2179
|
+
if (!isSessionValid(session, Date.now(), apiOrigin) || pendingOrganization) throw new Error("Sign in to an organization first.");
|
|
2180
|
+
const settingsSession = session;
|
|
2181
|
+
await writeSqmEnabled(sessionRoot, apiOrigin, settingsSession, enabled);
|
|
2182
|
+
if (session !== settingsSession) return;
|
|
2183
|
+
sqmEnabled = enabled;
|
|
2184
|
+
refreshAvailableCapabilities();
|
|
2185
|
+
notifyDesktopStatusChange();
|
|
2186
|
+
void heartbeat().catch(error => reportHeartbeatError(error, quiet));
|
|
2187
|
+
},
|
|
2048
2188
|
getDesktopStatus,
|
|
2049
|
-
getDesktopTask: (key) => desktopTaskDetail([registry, directRegistry, localTaskRegistry], key),
|
|
2189
|
+
getDesktopTask: (key) => sqmLocalReviewWorker?.desktopTaskDetail?.(key) ?? desktopTaskDetail([registry, directRegistry, localTaskRegistry], key),
|
|
2050
2190
|
async refreshDesktopStatus() {
|
|
2051
2191
|
if (isSessionValid(session, Date.now(), apiOrigin)) {
|
|
2052
2192
|
await heartbeat().catch((error) => reportHeartbeatError(error, quiet));
|
|
@@ -2738,6 +2878,17 @@ async function readJson(req) {
|
|
|
2738
2878
|
return raw ? JSON.parse(raw) : {};
|
|
2739
2879
|
}
|
|
2740
2880
|
|
|
2881
|
+
async function readBoundedJson(req, maxBytes) {
|
|
2882
|
+
const chunks = [];
|
|
2883
|
+
let size = 0;
|
|
2884
|
+
for await (const chunk of req) {
|
|
2885
|
+
size += chunk.length;
|
|
2886
|
+
if (size > maxBytes) throw new Error("body too large");
|
|
2887
|
+
chunks.push(chunk);
|
|
2888
|
+
}
|
|
2889
|
+
return JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(Buffer.concat(chunks)));
|
|
2890
|
+
}
|
|
2891
|
+
|
|
2741
2892
|
async function readCodingLoopStart(req) {
|
|
2742
2893
|
const contentType = String(req.headers["content-type"] || "");
|
|
2743
2894
|
if (!contentType.toLowerCase().startsWith("multipart/form-data")) {
|
package/src/cli-contract.js
CHANGED
|
@@ -13,7 +13,7 @@ import {
|
|
|
13
13
|
} from "./durable-local-task-steps-contract.js";
|
|
14
14
|
import { buildExecutionCapabilityContract } from "./execution-capability-contract.js";
|
|
15
15
|
|
|
16
|
-
export const CLI_VERSION = "0.5.
|
|
16
|
+
export const CLI_VERSION = "0.5.46";
|
|
17
17
|
export const CLI_API_CONTRACT_VERSION = 10;
|
|
18
18
|
|
|
19
19
|
export function withCliContract(
|
|
@@ -30,3 +30,17 @@ Private state is under `SIMY_HOME/delivery-hooks` (default `~/.simy/delivery-hoo
|
|
|
30
30
|
keyed by provider and session ID and bound to the canonical working directory.
|
|
31
31
|
Historical hook observations do not prove hooks are still enabled. See the
|
|
32
32
|
bundled delivery-loop skill for checkpoint commands and explicit recovery.
|
|
33
|
+
|
|
34
|
+
## Project 実装状況 (dev-work) hook
|
|
35
|
+
|
|
36
|
+
`dev-work.mjs` runs on Stop in both distributions. In a Git work tree with a
|
|
37
|
+
named branch, a commit and an `origin` remote it reports `owner/name`, branch,
|
|
38
|
+
HEAD, the open PR URL (via `gh pr view`, only when `gh` is installed) and the
|
|
39
|
+
Project from `git config simy.project-id` to the local SIMY CLI agent over
|
|
40
|
+
loopback (`POST /v1/dev-work-events`). The agent forwards it to SIMY Web as the
|
|
41
|
+
signed-in device; the hook never reads or holds a SIMY credential. The provider
|
|
42
|
+
session ID is sent only as a SHA-256 digest. An unchanged report for the same
|
|
43
|
+
session is skipped locally (state under `PLUGIN_DATA/dev-work`). The hook never
|
|
44
|
+
blocks: it has short timeouts, always exits 0 and writes only short error lines
|
|
45
|
+
to stderr. Link a repository to a Project with
|
|
46
|
+
`git config simy.project-id <project-uuid>`.
|
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// P-05: report this coding session's Git state (repo, branch, HEAD, optional
|
|
4
|
+
// PR and Project) to the local SIMY CLI agent, which forwards it to SIMY as
|
|
5
|
+
// the signed-in device. Shipped with both the Codex and Claude Code plugins.
|
|
6
|
+
//
|
|
7
|
+
// The hook holds no SIMY credential and never blocks the coding session: every
|
|
8
|
+
// step has a short timeout, failures are silent (a short stderr line at most,
|
|
9
|
+
// never a secret), and it always exits 0. The raw provider session id is never
|
|
10
|
+
// sent or stored; only a provider-scoped SHA-256 of it leaves this process.
|
|
11
|
+
|
|
12
|
+
import { execFile } from "node:child_process";
|
|
13
|
+
import { createHash } from "node:crypto";
|
|
14
|
+
import { mkdir, readFile, readdir, rename, writeFile } from "node:fs/promises";
|
|
15
|
+
import { homedir } from "node:os";
|
|
16
|
+
import { dirname, join } from "node:path";
|
|
17
|
+
import { pathToFileURL } from "node:url";
|
|
18
|
+
|
|
19
|
+
const MAX_INPUT_BYTES = 64 * 1024;
|
|
20
|
+
const MAX_DISCOVERY_RECORDS = 16;
|
|
21
|
+
const GIT_TIMEOUT_MS = 1_500;
|
|
22
|
+
const GH_TIMEOUT_MS = 3_000;
|
|
23
|
+
const POST_TIMEOUT_MS = 6_000;
|
|
24
|
+
const HARD_DEADLINE_MS = 9_000;
|
|
25
|
+
const SOURCES = { codex: "codex", claude: "claude_code" };
|
|
26
|
+
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
27
|
+
const HEAD_SHA = /^[0-9a-f]{40}$/;
|
|
28
|
+
const REPO = /^[A-Za-z0-9_.-]{1,100}\/[A-Za-z0-9_.-]{1,100}$/;
|
|
29
|
+
const PR_URL =
|
|
30
|
+
/^https:\/\/github\.com\/[A-Za-z0-9_.-]{1,100}\/[A-Za-z0-9_.-]{1,100}\/pull\/[0-9]{1,10}$/;
|
|
31
|
+
|
|
32
|
+
function run(command, args, { cwd, timeout, env }) {
|
|
33
|
+
return new Promise((resolve) => {
|
|
34
|
+
try {
|
|
35
|
+
execFile(
|
|
36
|
+
command,
|
|
37
|
+
args,
|
|
38
|
+
{ cwd, timeout, env, windowsHide: true, maxBuffer: 64 * 1024 },
|
|
39
|
+
(error, stdout) => resolve(error ? null : String(stdout).trim()),
|
|
40
|
+
);
|
|
41
|
+
} catch {
|
|
42
|
+
resolve(null);
|
|
43
|
+
}
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** owner/name from an origin remote URL (https, ssh or scp-like), or null. */
|
|
48
|
+
export function repoFromRemote(remote) {
|
|
49
|
+
const value = String(remote || "").trim();
|
|
50
|
+
if (!value) return null;
|
|
51
|
+
let path;
|
|
52
|
+
const scp = value.match(/^[^@/\s]+@[^:/\s]+:(.+)$/);
|
|
53
|
+
if (scp) {
|
|
54
|
+
path = scp[1];
|
|
55
|
+
} else {
|
|
56
|
+
try {
|
|
57
|
+
const url = new URL(value);
|
|
58
|
+
if (!["https:", "http:", "ssh:", "git:"].includes(url.protocol)) return null;
|
|
59
|
+
path = url.pathname;
|
|
60
|
+
} catch {
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
const parts = path.replace(/\.git$/i, "").replace(/\/+$/, "").split("/").filter(Boolean);
|
|
65
|
+
if (parts.length < 2) return null;
|
|
66
|
+
const repo = `${parts.at(-2)}/${parts.at(-1)}`;
|
|
67
|
+
return REPO.test(repo) ? repo : null;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Collects the report for `cwd`, or null when it is not a Git work tree with a
|
|
72
|
+
* named branch, a commit, and an origin remote.
|
|
73
|
+
*/
|
|
74
|
+
export async function collectDevWorkEvent({
|
|
75
|
+
cwd,
|
|
76
|
+
provider,
|
|
77
|
+
sessionId,
|
|
78
|
+
env = process.env,
|
|
79
|
+
runImpl = run,
|
|
80
|
+
}) {
|
|
81
|
+
const source = SOURCES[provider];
|
|
82
|
+
if (!source || typeof sessionId !== "string" || !sessionId.trim() || !cwd) return null;
|
|
83
|
+
const git = (args) => runImpl("git", args, { cwd, timeout: GIT_TIMEOUT_MS, env });
|
|
84
|
+
if ((await git(["rev-parse", "--is-inside-work-tree"])) !== "true") return null;
|
|
85
|
+
const [headSha, branch, remote, projectId] = await Promise.all([
|
|
86
|
+
git(["rev-parse", "--verify", "-q", "HEAD"]),
|
|
87
|
+
git(["symbolic-ref", "--short", "-q", "HEAD"]),
|
|
88
|
+
git(["remote", "get-url", "origin"]),
|
|
89
|
+
git(["config", "--get", "simy.project-id"]),
|
|
90
|
+
]);
|
|
91
|
+
const repo = repoFromRemote(remote);
|
|
92
|
+
if (!headSha || !HEAD_SHA.test(headSha) || !branch || !repo) return null;
|
|
93
|
+
// Only repositories the owner linked to a Project (`git config simy.project-id`)
|
|
94
|
+
// are reported; other repositories' names and branches never leave the machine.
|
|
95
|
+
if (!projectId || !UUID.test(projectId)) return null;
|
|
96
|
+
return {
|
|
97
|
+
repo,
|
|
98
|
+
branch,
|
|
99
|
+
head_sha: headSha,
|
|
100
|
+
session_id: sha256(`${provider}:${sessionId.trim()}`),
|
|
101
|
+
source,
|
|
102
|
+
project_id: projectId.toLowerCase(),
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Best effort: the open PR for the current branch via `gh`, if installed. */
|
|
107
|
+
export async function pullRequestUrl({ cwd, repo, env = process.env, runImpl = run }) {
|
|
108
|
+
const url = await runImpl("gh", ["pr", "view", "--json", "url", "--jq", ".url"], {
|
|
109
|
+
cwd,
|
|
110
|
+
timeout: GH_TIMEOUT_MS,
|
|
111
|
+
env: { ...env, GH_PROMPT_DISABLED: "1", NO_COLOR: "1" },
|
|
112
|
+
});
|
|
113
|
+
if (!url || !PR_URL.test(url)) return null;
|
|
114
|
+
return url.toLowerCase().startsWith(`https://github.com/${repo.toLowerCase()}/pull/`) ? url : null;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export async function findAgentPort({ env = process.env, sessionRoot }) {
|
|
118
|
+
const explicit = normalizeOrigin(env.SIMY_WEB_ORIGIN || env.SIMY_API_ORIGIN);
|
|
119
|
+
if (env.SIMY_WEB_ORIGIN || env.SIMY_API_ORIGIN) {
|
|
120
|
+
return explicit ? readPort(sessionRoot, explicit) : null;
|
|
121
|
+
}
|
|
122
|
+
let entries;
|
|
123
|
+
try {
|
|
124
|
+
entries = (await readdir(join(sessionRoot, "connections")))
|
|
125
|
+
.filter((entry) => entry.endsWith(".json"))
|
|
126
|
+
.sort()
|
|
127
|
+
.slice(0, MAX_DISCOVERY_RECORDS);
|
|
128
|
+
} catch {
|
|
129
|
+
return null;
|
|
130
|
+
}
|
|
131
|
+
for (const entry of entries) {
|
|
132
|
+
try {
|
|
133
|
+
const record = JSON.parse(await readFile(join(sessionRoot, "connections", entry), "utf8"));
|
|
134
|
+
const origin = normalizeOrigin(record?.web_origin);
|
|
135
|
+
if (!origin || entry !== `${sha256(origin)}.json`) continue;
|
|
136
|
+
const port = await readPort(sessionRoot, origin);
|
|
137
|
+
if (port) return port;
|
|
138
|
+
} catch {
|
|
139
|
+
// Unreadable discovery records are skipped.
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
return null;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
async function readPort(sessionRoot, origin) {
|
|
146
|
+
try {
|
|
147
|
+
const record = JSON.parse(
|
|
148
|
+
await readFile(join(sessionRoot, "connections", `${sha256(origin)}.json`), "utf8"),
|
|
149
|
+
);
|
|
150
|
+
return record?.schema_version === 1 &&
|
|
151
|
+
record?.web_origin === origin &&
|
|
152
|
+
Number.isInteger(record?.port) &&
|
|
153
|
+
record.port >= 1 &&
|
|
154
|
+
record.port <= 65_535
|
|
155
|
+
? record.port
|
|
156
|
+
: null;
|
|
157
|
+
} catch {
|
|
158
|
+
return null;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function normalizeOrigin(raw) {
|
|
163
|
+
try {
|
|
164
|
+
const url = new URL(raw);
|
|
165
|
+
if (!["http:", "https:"].includes(url.protocol) || url.username || url.password) return null;
|
|
166
|
+
if ((url.pathname && url.pathname !== "/") || url.search || url.hash) return null;
|
|
167
|
+
return url.origin;
|
|
168
|
+
} catch {
|
|
169
|
+
return null;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export async function runDevWorkHook({
|
|
174
|
+
provider,
|
|
175
|
+
input = {},
|
|
176
|
+
env = process.env,
|
|
177
|
+
fetchImpl = globalThis.fetch,
|
|
178
|
+
runImpl = run,
|
|
179
|
+
}) {
|
|
180
|
+
const cwd = typeof input.cwd === "string" && input.cwd ? input.cwd : process.cwd();
|
|
181
|
+
const sessionId = input.session_id || input.sessionId || env.CODEX_THREAD_ID;
|
|
182
|
+
const event = await collectDevWorkEvent({ cwd, provider, sessionId, env, runImpl });
|
|
183
|
+
if (!event) return { sent: false, reason: "not_applicable" };
|
|
184
|
+
|
|
185
|
+
const sessionRoot = env.SIMY_HOME?.trim() || join(homedir(), ".simy");
|
|
186
|
+
const pluginData =
|
|
187
|
+
env.PLUGIN_DATA?.trim() || env.CLAUDE_PLUGIN_DATA?.trim() || join(sessionRoot, "plugin-data", "simy");
|
|
188
|
+
// The upstream observation is keyed by (Project, repo, HEAD, session): a
|
|
189
|
+
// resend of the same state changes nothing, so skip it (and `gh`) locally.
|
|
190
|
+
const fingerprint = sha256(JSON.stringify([event.session_id, event.repo, event.head_sha, event.project_id ?? null]));
|
|
191
|
+
const statePath = join(pluginData, "dev-work", `${event.session_id}.json`);
|
|
192
|
+
if ((await readState(statePath))?.fingerprint === fingerprint) {
|
|
193
|
+
return { sent: false, reason: "unchanged" };
|
|
194
|
+
}
|
|
195
|
+
const port = await findAgentPort({ env, sessionRoot });
|
|
196
|
+
if (!port) return { sent: false, reason: "agent_unavailable" };
|
|
197
|
+
const prUrl = await pullRequestUrl({ cwd, repo: event.repo, env, runImpl });
|
|
198
|
+
if (prUrl) event.pr_url = prUrl;
|
|
199
|
+
|
|
200
|
+
const response = await fetchImpl(`http://127.0.0.1:${port}/v1/dev-work-events`, {
|
|
201
|
+
method: "POST",
|
|
202
|
+
headers: { "Content-Type": "application/json" },
|
|
203
|
+
body: JSON.stringify(event),
|
|
204
|
+
redirect: "error",
|
|
205
|
+
signal: AbortSignal.timeout(POST_TIMEOUT_MS),
|
|
206
|
+
});
|
|
207
|
+
await response.body?.cancel?.().catch(() => {});
|
|
208
|
+
if (!response.ok) return { sent: false, reason: `http_${response.status}` };
|
|
209
|
+
await writeState(statePath, { fingerprint }).catch(() => {});
|
|
210
|
+
return { sent: true };
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
async function readState(target) {
|
|
214
|
+
try {
|
|
215
|
+
return JSON.parse(await readFile(target, "utf8"));
|
|
216
|
+
} catch {
|
|
217
|
+
return null;
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
async function writeState(target, state) {
|
|
222
|
+
await mkdir(dirname(target), { recursive: true, mode: 0o700 });
|
|
223
|
+
const temporary = `${target}.${process.pid}.tmp`;
|
|
224
|
+
await writeFile(temporary, `${JSON.stringify(state)}\n`, { encoding: "utf8", mode: 0o600 });
|
|
225
|
+
await rename(temporary, target);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function sha256(value) {
|
|
229
|
+
return createHash("sha256").update(value).digest("hex");
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
async function readHookInput(stream = process.stdin) {
|
|
233
|
+
let raw = "";
|
|
234
|
+
for await (const chunk of stream) {
|
|
235
|
+
raw += String(chunk);
|
|
236
|
+
if (Buffer.byteLength(raw) > MAX_INPUT_BYTES) return {};
|
|
237
|
+
}
|
|
238
|
+
try {
|
|
239
|
+
const parsed = JSON.parse(raw || "{}");
|
|
240
|
+
return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {};
|
|
241
|
+
} catch {
|
|
242
|
+
return {};
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
export async function main(provider = "codex") {
|
|
247
|
+
// Never outlive the provider's hook budget, whatever a child process does.
|
|
248
|
+
const deadline = setTimeout(() => process.exit(0), HARD_DEADLINE_MS);
|
|
249
|
+
deadline.unref?.();
|
|
250
|
+
try {
|
|
251
|
+
const result = await runDevWorkHook({ provider, input: await readHookInput() });
|
|
252
|
+
// Signed out (401) and a disabled Web gateway (404) are expected states.
|
|
253
|
+
if (
|
|
254
|
+
!result.sent &&
|
|
255
|
+
result.reason.startsWith("http_") &&
|
|
256
|
+
!["http_401", "http_404"].includes(result.reason)
|
|
257
|
+
) {
|
|
258
|
+
process.stderr.write(`SIMY dev-work report was not recorded (${result.reason}).\n`);
|
|
259
|
+
}
|
|
260
|
+
} catch {
|
|
261
|
+
process.stderr.write("SIMY dev-work report failed.\n");
|
|
262
|
+
} finally {
|
|
263
|
+
clearTimeout(deadline);
|
|
264
|
+
}
|
|
265
|
+
process.exitCode = 0;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
|
269
|
+
await main(process.argv[2] === "claude" ? "claude" : "codex");
|
|
270
|
+
}
|
|
@@ -50,6 +50,15 @@
|
|
|
50
50
|
"timeout": 3
|
|
51
51
|
}
|
|
52
52
|
]
|
|
53
|
+
},
|
|
54
|
+
{
|
|
55
|
+
"hooks": [
|
|
56
|
+
{
|
|
57
|
+
"type": "command",
|
|
58
|
+
"command": "node --input-type=module -e \"import { pathToFileURL } from 'node:url'; import { join } from 'node:path'; const hook = await import(pathToFileURL(join(process.env.PLUGIN_ROOT, 'hooks', 'dev-work.mjs')).href); await hook.main('codex');\"",
|
|
59
|
+
"timeout": 10
|
|
60
|
+
}
|
|
61
|
+
]
|
|
53
62
|
}
|
|
54
63
|
],
|
|
55
64
|
"SessionEnd": [
|
package/src/desktop-host.js
CHANGED
|
@@ -37,6 +37,21 @@ const silentLogger = { log() {}, error() {} };
|
|
|
37
37
|
// exceed 128 KiB, including after a signed runtime handoff.
|
|
38
38
|
const MAX_DESKTOP_FRAME_BYTES = 8 * 1024 * 1024;
|
|
39
39
|
|
|
40
|
+
function desktopActionError(action, error) {
|
|
41
|
+
if (typeof action === "string" && action.startsWith("session_")) {
|
|
42
|
+
return action.startsWith("session_action_") ? associationActionError(error) : sessionActionError(error);
|
|
43
|
+
}
|
|
44
|
+
if (["organization_select", "organization_cancel", "organizations_refresh"].includes(action)) {
|
|
45
|
+
return error?.code === "desktop_organization_busy"
|
|
46
|
+
? "Wait for running tasks to finish before switching organizations."
|
|
47
|
+
: "Could not switch organization. Refresh organizations and try again.";
|
|
48
|
+
}
|
|
49
|
+
if (action === "sqm_set_enabled") {
|
|
50
|
+
return "Could not save SQM settings. Check your SIMY connection and try again.";
|
|
51
|
+
}
|
|
52
|
+
return "Action could not finish. Refresh the status and try again; active tasks must finish before quitting.";
|
|
53
|
+
}
|
|
54
|
+
|
|
40
55
|
export async function startDesktopHost({ input = process.stdin, output = process.stdout, env = process.env,
|
|
41
56
|
dependencies = {}, exit = (code) => { process.exitCode = code; } } = {}) {
|
|
42
57
|
const emit = (event) => output.write(`${JSON.stringify(event)}\n`);
|
|
@@ -270,12 +285,16 @@ export async function startDesktopHost({ input = process.stdin, output = process
|
|
|
270
285
|
refreshSessions(true);
|
|
271
286
|
void actionAssociations.tick().then(publish).catch(() => {});
|
|
272
287
|
}
|
|
273
|
-
else if (action === "
|
|
274
|
-
|
|
288
|
+
else if (action === "organizations_refresh") await agent.refreshOrganizations();
|
|
289
|
+
else if (action === "sqm_set_enabled") await agent.setSqmEnabled(command.enabled);
|
|
290
|
+
else if (action === "organization_cancel") agent.cancelOrganizationSelection();
|
|
291
|
+
else if (action === "login" || action === "organization_select") {
|
|
292
|
+
if (action === "organization_select" && (typeof command.organizationId !== "string" || !command.organizationId)) throw new Error("Select an organization first.");
|
|
293
|
+
const url = await agent.requestDesktopLogin(action === "organization_select" ? command.organizationId : null);
|
|
275
294
|
if (url) {
|
|
276
295
|
if (new URL(url).origin !== webOrigin) throw new Error("Invalid login origin.");
|
|
277
296
|
const result = await (dependencies.openLogin || openAuthorizationUrl)(url, { logger: silentLogger });
|
|
278
|
-
if (!result.opened) throw new Error("Could not open the login page.");
|
|
297
|
+
if (!result.opened) { agent.cancelOrganizationSelection?.(); throw new Error("Could not open the login page."); }
|
|
279
298
|
}
|
|
280
299
|
} else if (action === "check_update") await updater.checkNow();
|
|
281
300
|
else if (action === "update") {
|
|
@@ -376,15 +395,14 @@ export async function startDesktopHost({ input = process.stdin, output = process
|
|
|
376
395
|
}
|
|
377
396
|
else if (action === "quit") {
|
|
378
397
|
const status = await agent.getDesktopStatus();
|
|
379
|
-
if (status.tasks.activeCount > 0 && command.force !== true) throw new Error("Tasks are still running. Confirm before quitting.");
|
|
398
|
+
if ((status.tasks.activeCount + (status.sqmTasks?.activeCount || 0)) > 0 && command.force !== true) throw new Error("Tasks are still running. Confirm before quitting.");
|
|
380
399
|
emit({ type: "result", id: command.id, ok: true });
|
|
381
400
|
await close(); exit(0); return;
|
|
382
401
|
} else throw new Error("Unsupported desktop action.");
|
|
383
402
|
emit({ type: "result", id: command.id, ok: true });
|
|
384
403
|
await publish();
|
|
385
404
|
} catch (error) {
|
|
386
|
-
emit({ type: "result", id: command.id, ok: false, message:
|
|
387
|
-
? (String(command.action).startsWith("session_action_") ? associationActionError(error) : sessionActionError(error)) : "Action could not finish. Refresh the status and try again; active tasks must finish before quitting." });
|
|
405
|
+
emit({ type: "result", id: command.id, ok: false, message: desktopActionError(command.action, error) });
|
|
388
406
|
}
|
|
389
407
|
}).catch(() => {});
|
|
390
408
|
});
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { mkdir, readFile, rename, writeFile } from 'node:fs/promises';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
|
|
5
|
+
export function sqmSettingsPath(root, origin, session) {
|
|
6
|
+
if (!session?.account_id || !session?.organization_id) return null;
|
|
7
|
+
const key = createHash('sha256').update(JSON.stringify([origin, session.account_id, session.organization_id])).digest('hex');
|
|
8
|
+
return join(root, 'sqm-settings', `${key}.json`);
|
|
9
|
+
}
|
|
10
|
+
export async function readSqmEnabled(root, origin, session) {
|
|
11
|
+
const path = sqmSettingsPath(root, origin, session);
|
|
12
|
+
if (!path) return false;
|
|
13
|
+
try { return JSON.parse(await readFile(path, 'utf8')).enabled === true; }
|
|
14
|
+
catch (error) { if (error.code === 'ENOENT' || error instanceof SyntaxError) return false; throw error; }
|
|
15
|
+
}
|
|
16
|
+
export async function writeSqmEnabled(root, origin, session, enabled) {
|
|
17
|
+
const path = sqmSettingsPath(root, origin, session);
|
|
18
|
+
if (!path || typeof enabled !== 'boolean') throw new Error('Sign in to an organization before enabling SQM.');
|
|
19
|
+
await mkdir(join(root, 'sqm-settings'), { recursive: true, mode: 0o700 });
|
|
20
|
+
const temporary = `${path}.${process.pid}.tmp`;
|
|
21
|
+
await writeFile(temporary, JSON.stringify({ enabled }), { mode: 0o600 });
|
|
22
|
+
await rename(temporary, path);
|
|
23
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { webApiHeaders, webApiUrl } from "./web-api.js";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* P-05: forwards a local coding session's Git state (reported by the SIMY
|
|
5
|
+
* plugin hook) to SIMY Web as the signed-in device, so the Project 実装状況
|
|
6
|
+
* tab can show it. The hook never holds a SIMY credential; this agent adds the
|
|
7
|
+
* device bearer. The Web gateway derives the owner scope from that bearer.
|
|
8
|
+
*/
|
|
9
|
+
// Trailing slash: SIMY Web redirects slashless API paths (308), and this
|
|
10
|
+
// sender refuses redirects so the device token is only sent to this exact URL.
|
|
11
|
+
export const PROJECT_DEV_WORK_EVENTS_PATH = "project-dev-work-events/";
|
|
12
|
+
export const DEV_WORK_EVENT_MAX_BYTES = 4096;
|
|
13
|
+
const REQUEST_TIMEOUT_MS = 5_000;
|
|
14
|
+
|
|
15
|
+
const REQUIRED_KEYS = ["repo", "branch", "head_sha", "session_id", "source"];
|
|
16
|
+
const OPTIONAL_KEYS = ["pr_url", "project_id"];
|
|
17
|
+
const ALLOWED_KEYS = new Set([...REQUIRED_KEYS, ...OPTIONAL_KEYS]);
|
|
18
|
+
const SOURCES = new Set(["claude_code", "codex", "other"]);
|
|
19
|
+
const REPO = /^[A-Za-z0-9_.-]{1,100}\/[A-Za-z0-9_.-]{1,100}$/;
|
|
20
|
+
const HEAD_SHA = /^[0-9a-f]{7,40}$/;
|
|
21
|
+
// Same conservative `git check-ref-format` subset as the Web gateway.
|
|
22
|
+
const BRANCH = /^(?!-)(?!\/)(?!.*\.\.)(?!.*@\{)(?!.*\/$)(?!.*\.lock$)[^\x00-\x20\x7f~^:?*[\\]{1,255}$/;
|
|
23
|
+
const SESSION_ID = /^[A-Za-z0-9._:-]{1,128}$/;
|
|
24
|
+
const PR_URL =
|
|
25
|
+
/^https:\/\/github\.com\/[A-Za-z0-9_.-]{1,100}\/[A-Za-z0-9_.-]{1,100}\/pull\/[0-9]{1,10}$/;
|
|
26
|
+
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
27
|
+
|
|
28
|
+
export class DevWorkEventError extends Error {
|
|
29
|
+
constructor(message, status) {
|
|
30
|
+
super(message);
|
|
31
|
+
this.status = status;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Returns the exact report to forward, or throws a 400 DevWorkEventError. */
|
|
36
|
+
export function normalizeDevWorkEvent(value) {
|
|
37
|
+
const invalid = () => new DevWorkEventError("Invalid dev-work event.", 400);
|
|
38
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) throw invalid();
|
|
39
|
+
if (!Object.keys(value).every((key) => ALLOWED_KEYS.has(key))) throw invalid();
|
|
40
|
+
if (!REQUIRED_KEYS.every((key) => typeof value[key] === "string")) throw invalid();
|
|
41
|
+
const event = {};
|
|
42
|
+
for (const key of REQUIRED_KEYS) event[key] = value[key];
|
|
43
|
+
for (const key of OPTIONAL_KEYS) {
|
|
44
|
+
if (value[key] === undefined || value[key] === null) continue;
|
|
45
|
+
if (typeof value[key] !== "string") throw invalid();
|
|
46
|
+
event[key] = value[key];
|
|
47
|
+
}
|
|
48
|
+
if (
|
|
49
|
+
!REPO.test(event.repo) ||
|
|
50
|
+
!BRANCH.test(event.branch) ||
|
|
51
|
+
!HEAD_SHA.test(event.head_sha) ||
|
|
52
|
+
!SESSION_ID.test(event.session_id) ||
|
|
53
|
+
!SOURCES.has(event.source) ||
|
|
54
|
+
(event.pr_url !== undefined && !PR_URL.test(event.pr_url)) ||
|
|
55
|
+
(event.project_id !== undefined && !UUID.test(event.project_id))
|
|
56
|
+
) {
|
|
57
|
+
throw invalid();
|
|
58
|
+
}
|
|
59
|
+
if (event.project_id !== undefined) event.project_id = event.project_id.toLowerCase();
|
|
60
|
+
return event;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Posts one normalized report to Web. Resolves with the minimal receipt; a
|
|
65
|
+
* disabled gateway (404) or any refusal rejects with a status the local
|
|
66
|
+
* endpoint relays, without Web's message body.
|
|
67
|
+
*/
|
|
68
|
+
export async function sendDevWorkEvent({ event, session, apiOrigin, fetchImpl = globalThis.fetch }) {
|
|
69
|
+
let response;
|
|
70
|
+
try {
|
|
71
|
+
response = await fetchImpl(
|
|
72
|
+
webApiUrl(PROJECT_DEV_WORK_EVENTS_PATH, {
|
|
73
|
+
webOrigin: apiOrigin,
|
|
74
|
+
apiBaseUrl: session.api_base_url,
|
|
75
|
+
}),
|
|
76
|
+
{
|
|
77
|
+
method: "POST",
|
|
78
|
+
headers: webApiHeaders({ token: session.token }, { "Content-Type": "application/json" }),
|
|
79
|
+
body: JSON.stringify(event),
|
|
80
|
+
redirect: "error",
|
|
81
|
+
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
|
82
|
+
},
|
|
83
|
+
);
|
|
84
|
+
} catch {
|
|
85
|
+
throw new DevWorkEventError("SIMY Web could not be reached.", 502);
|
|
86
|
+
}
|
|
87
|
+
if (!response.ok) {
|
|
88
|
+
await response.body?.cancel?.().catch(() => {});
|
|
89
|
+
const status = [401, 403, 404, 409, 422, 503].includes(response.status) ? response.status : 502;
|
|
90
|
+
throw new DevWorkEventError("SIMY Web did not record the dev-work event.", status);
|
|
91
|
+
}
|
|
92
|
+
const receipt = await response.json().catch(() => null);
|
|
93
|
+
if (
|
|
94
|
+
!receipt ||
|
|
95
|
+
typeof receipt.job_id !== "string" ||
|
|
96
|
+
!UUID.test(receipt.job_id) ||
|
|
97
|
+
typeof receipt.created !== "boolean" ||
|
|
98
|
+
receipt.project_id !== (event.project_id ?? null)
|
|
99
|
+
) {
|
|
100
|
+
throw new DevWorkEventError("SIMY Web returned an invalid dev-work receipt.", 502);
|
|
101
|
+
}
|
|
102
|
+
return { job_id: receipt.job_id, created: receipt.created, project_id: receipt.project_id };
|
|
103
|
+
}
|
package/src/sqm/claude-plugin.js
CHANGED
|
@@ -24,6 +24,7 @@ export async function claudePluginFiles() {
|
|
|
24
24
|
files.set("hooks/action-association.mjs", await readFile(path.join(SOURCE, "hooks/action-association.mjs"), "utf8"));
|
|
25
25
|
files.set("hooks/workspace-lease.mjs", await readFile(path.join(SOURCE, "hooks/workspace-lease.mjs"), "utf8"));
|
|
26
26
|
files.set("hooks/connection-reminder.mjs", await readFile(path.join(SOURCE, "hooks/connection-reminder.mjs"), "utf8"));
|
|
27
|
+
files.set("hooks/dev-work.mjs", await readFile(path.join(SOURCE, "hooks/dev-work.mjs"), "utf8"));
|
|
27
28
|
const hooks = JSON.parse(await readFile(path.join(SOURCE, "hooks/hooks.json"), "utf8"));
|
|
28
29
|
delete hooks.hooks.Interrupt; // Codex-only event; Claude interruptions cannot be vetoed.
|
|
29
30
|
hooks.hooks.StopFailure = structuredClone(hooks.hooks.Stop);
|
package/src/sqm/review-worker.js
CHANGED
|
@@ -83,11 +83,19 @@ export function createSqmLocalReviewWorker({
|
|
|
83
83
|
runReview = (request, provider) => runStructuredProviderReview(provider, request),
|
|
84
84
|
beginWork = () => () => {},
|
|
85
85
|
onError = () => {},
|
|
86
|
+
onChange = () => {},
|
|
86
87
|
} = {}) {
|
|
87
88
|
if (!api || typeof api.request !== "function") throw new TypeError("api.request is required");
|
|
88
89
|
let activeJobId = null;
|
|
89
90
|
let pollPromise = null;
|
|
90
91
|
let closed = false;
|
|
92
|
+
let issue = null;
|
|
93
|
+
const history = [];
|
|
94
|
+
const updateTask = (id, state) => {
|
|
95
|
+
const item = history.find(item => item.id === id);
|
|
96
|
+
if (item) item.state = state;
|
|
97
|
+
onChange();
|
|
98
|
+
};
|
|
91
99
|
|
|
92
100
|
const leaseBody = (claim, provider) => ({
|
|
93
101
|
executor_provider: provider,
|
|
@@ -99,6 +107,10 @@ export function createSqmLocalReviewWorker({
|
|
|
99
107
|
|
|
100
108
|
const processClaim = async (claim, provider) => {
|
|
101
109
|
activeJobId = claim.job_id;
|
|
110
|
+
history.unshift({ id: claim.job_id, key: `sqm:${claim.job_id}`, label: `SQM · ${String(claim.job_id).slice(0, 8)}`,
|
|
111
|
+
state: "running", provider, startedAt: new Date().toISOString() });
|
|
112
|
+
history.splice(50);
|
|
113
|
+
onChange();
|
|
102
114
|
let heartbeatTimer = null;
|
|
103
115
|
const heartbeat = () => {
|
|
104
116
|
void api.request({ action: "heartbeat", ...leaseBody(claim, provider) }).catch(onError);
|
|
@@ -113,25 +125,37 @@ export function createSqmLocalReviewWorker({
|
|
|
113
125
|
analysis_output: analysis.output,
|
|
114
126
|
executor_model: analysis.model,
|
|
115
127
|
});
|
|
116
|
-
if (advanced.completed) return advanced;
|
|
128
|
+
if (advanced.completed) { updateTask(claim.job_id, "completed"); return advanced; }
|
|
117
129
|
const adjudication = await runReview(advanced.request, provider);
|
|
118
|
-
|
|
130
|
+
const completed = await api.request({
|
|
119
131
|
action: "complete",
|
|
120
132
|
...leaseBody(claim, provider),
|
|
121
133
|
analysis_output: analysis.output,
|
|
122
134
|
adjudication_output: adjudication.output,
|
|
123
135
|
executor_model: adjudication.model || analysis.model,
|
|
124
136
|
});
|
|
137
|
+
updateTask(claim.job_id, "completed");
|
|
138
|
+
return completed;
|
|
139
|
+
} catch (error) {
|
|
140
|
+
updateTask(claim.job_id, "failed");
|
|
141
|
+
throw error;
|
|
125
142
|
} finally {
|
|
126
143
|
if (heartbeatTimer) clearInterval(heartbeatTimer);
|
|
127
144
|
activeJobId = null;
|
|
145
|
+
onChange();
|
|
128
146
|
}
|
|
129
147
|
};
|
|
130
148
|
|
|
131
149
|
return {
|
|
132
150
|
snapshot() {
|
|
133
|
-
return { active_job_id: activeJobId, closed };
|
|
151
|
+
return { active_job_id: activeJobId, polling: Boolean(pollPromise), closed, issue };
|
|
152
|
+
},
|
|
153
|
+
desktopTasks() { return { activeCount: activeJobId ? 1 : 0, items: history.map(item => ({ ...item })) }; },
|
|
154
|
+
desktopTaskDetail(key) {
|
|
155
|
+
const item = history.find(item => item.key === key);
|
|
156
|
+
return item ? { key, instruction: `SQM local review · ${item.provider}`, result: item.state === "completed" ? "SQM review submitted." : "", error: item.state === "failed" ? "Local review failed. Check the SQM workspace for retry status." : "", links: [] } : null;
|
|
134
157
|
},
|
|
158
|
+
resetHistory() { if (!pollPromise) { history.splice(0); issue = null; } },
|
|
135
159
|
poll() {
|
|
136
160
|
if (closed || pollPromise) return pollPromise || Promise.resolve(null);
|
|
137
161
|
const provider = getProvider();
|
|
@@ -146,16 +170,19 @@ export function createSqmLocalReviewWorker({
|
|
|
146
170
|
worker_id: workerId,
|
|
147
171
|
lease_seconds: leaseSeconds,
|
|
148
172
|
});
|
|
173
|
+
if (issue) { issue = null; onChange(); }
|
|
149
174
|
if (!claim?.claimed) return null;
|
|
150
175
|
return processClaim(claim, provider);
|
|
151
176
|
})()
|
|
152
177
|
.catch((error) => {
|
|
178
|
+
issue = error.status === 403 ? "forbidden" : error.status === 401 ? "signed_out" : "unavailable";
|
|
153
179
|
onError(error);
|
|
154
180
|
return null;
|
|
155
181
|
})
|
|
156
182
|
.finally(() => {
|
|
157
183
|
pollPromise = null;
|
|
158
184
|
releaseWork();
|
|
185
|
+
onChange();
|
|
159
186
|
});
|
|
160
187
|
return pollPromise;
|
|
161
188
|
},
|