@awak-app/simy-cli 0.2.0 → 0.2.2

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 CHANGED
@@ -36,6 +36,14 @@ runs. It builds the requirement charter, runs Codex or Claude Code, audits
36
36
  structured completion evidence, and re-instructs the executor within the
37
37
  configured attempt budget.
38
38
 
39
+ Before Web creates an Agentic Loop, it classifies whether the request actually
40
+ needs a managed PR, verification, evidence, and merge lifecycle. A bounded
41
+ ordinary task is sent to CLI 0.2.2 or newer through the one-time direct executor
42
+ instead. CLI independently recomputes the guardrail, restricts read-only work at
43
+ the Provider command boundary, and permits exactly one Provider invocation. It
44
+ does not create an Agentic Loop ledger, audit session, or retry lifecycle for
45
+ that task.
46
+
39
47
  ## CLI updates
40
48
 
41
49
  The CLI checks the npm registry at startup and once per hour while it is
@@ -93,6 +101,19 @@ executor updates, repository selection, and CLI reconnection. Web clients must
93
101
  validate the action type before rendering or executing it; raw local error text
94
102
  is not copied into the recovery contract.
95
103
 
104
+ ## Provider token budget
105
+
106
+ Each run accepts a configurable `token_budget` (or the explicit
107
+ `provider_token_budget` alias) from 1 to 10,000,000 Provider tokens. The default
108
+ is 250,000. Provider tokens are the Codex or Claude Code usage reported for the
109
+ run; they are a safety limit and are not billed as SIMY tokens.
110
+
111
+ When a run reaches this limit, its recovery contract suggests a higher rounded
112
+ limit. SIMY Web can edit that suggestion and call
113
+ `POST /v1/agentic-loop/:run_id/provider-token-budget` with
114
+ `provider_token_budget`. The CLI persists the higher limit and resumes the same
115
+ run. SIMY token consumption remains a separate platform billing record.
116
+
96
117
  ## Interactive console
97
118
 
98
119
  Running `simy` in a terminal opens the Agentic Loop chat. It discovers the
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@awak-app/simy-cli",
3
- "version": "0.2.0",
3
+ "version": "0.2.2",
4
4
  "description": "Local SIMY Agentic Loop executor for Codex and Claude Code.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -25,12 +25,13 @@
25
25
  "test:e2e:hypothesis-retries": "node ./scripts/hypothesis-retries-e2e.js",
26
26
  "test:e2e:budgets": "node ./scripts/budget-enforcement-e2e.js",
27
27
  "test:e2e:desktop-executor": "node ./scripts/desktop-executor-e2e.js",
28
+ "test:e2e:pre-guardrail": "node ./scripts/pre-guardrail-e2e.js",
28
29
  "test:e2e:console:real-provider": "node ./scripts/real-provider-console-e2e.js",
29
30
  "test:e2e:console:fullstack": "node ./scripts/fullstack-cli-smoke.js",
30
- "check": "npm run check:auto-update && node --check ./src/desktop-executor.js && node --check ./scripts/desktop-executor-e2e.js && node --check ./scripts/fixtures/fake-desktop-provider.js && npm run check:syntax && npm run check:real-provider-syntax && npm test && npm run check:package",
31
+ "check": "npm run check:auto-update && node --check ./src/desktop-executor.js && node --check ./scripts/desktop-executor-e2e.js && node --check ./scripts/pre-guardrail-e2e.js && node --check ./scripts/fixtures/fake-desktop-provider.js && npm run check:syntax && npm run check:real-provider-syntax && npm test && npm run check:package",
31
32
  "check:auto-update": "node --check ./src/auto-update.js && node --check ./scripts/auto-update-e2e.js",
32
33
  "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",
33
- "check:syntax": "node --check ./src/index.js && node --check ./src/agent.js && node --check ./src/browser.js && node --check ./src/web-api.js && node --check ./src/run-registry.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/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 ./scripts/fixtures/fake-coding-backend.js && node --check ./scripts/e2e-terminal-evidence.js && node --check ./scripts/audit-gate-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/console-e2e-fixture.js && node --check ./scripts/console-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",
34
+ "check:syntax": "node --check ./src/index.js && node --check ./src/agent.js && node --check ./src/browser.js && node --check ./src/web-api.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/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 ./scripts/fixtures/fake-coding-backend.js && node --check ./scripts/e2e-terminal-evidence.js && node --check ./scripts/audit-gate-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/console-e2e-fixture.js && node --check ./scripts/console-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",
34
35
  "check:package": "node ./scripts/check-package-contents.js"
35
36
  },
36
37
  "engines": {
package/src/agent.js CHANGED
@@ -7,6 +7,7 @@ import {
7
7
  continueLocalCodingRun,
8
8
  continueLocalCodingRunAfterRepositoryApproval,
9
9
  createRun,
10
+ increaseLocalCodingRunProviderTokenBudget,
10
11
  LocalRunRegistry,
11
12
  isLocalRepositoryApprovalPending,
12
13
  pauseLocalCodingRun,
@@ -69,6 +70,16 @@ import {
69
70
  CLI_VERSION,
70
71
  withCliContract,
71
72
  } from "./cli-contract.js";
73
+ import {
74
+ createDirectTask,
75
+ directTaskSnapshot,
76
+ DirectTaskRegistry,
77
+ startDirectTask,
78
+ } from "./direct-executor.js";
79
+ import {
80
+ classifyExecutionRequirement,
81
+ EXECUTION_GUARDRAIL_POLICY_VERSION,
82
+ } from "./execution-guardrail.js";
72
83
 
73
84
  const DEVICE_HEARTBEAT_INTERVAL_MS = 20_000;
74
85
 
@@ -83,6 +94,7 @@ export async function startAgent({
83
94
  } = {}) {
84
95
  const apiOrigin = resolveWebOrigin(webOrigin);
85
96
  const registry = new LocalRunRegistry();
97
+ const directRegistry = new DirectTaskRegistry();
86
98
  const authNonce = randomBytes(16).toString("base64url");
87
99
  let session = await readSession(apiOrigin, sessionRoot);
88
100
  if (session && sessionRequiresWebAuthorization(session, apiOrigin)) session = null;
@@ -376,6 +388,112 @@ export async function startAgent({
376
388
  void synchronizeAuthorizedSession();
377
389
  return;
378
390
  }
391
+ const directExecutionMatch = url.pathname.match(/^\/v1\/direct-execution\/([^/]+)$/);
392
+ if (req.method === "GET" && directExecutionMatch) {
393
+ if (!isSessionValid(session, Date.now(), apiOrigin)) {
394
+ json(res, 401, { error: "simy session expired; run simy again" });
395
+ return;
396
+ }
397
+ const task = directRegistry.get(decodeURIComponent(directExecutionMatch[1]));
398
+ if (!task) {
399
+ json(res, 404, { error: "direct task not found" });
400
+ return;
401
+ }
402
+ json(res, 200, { task: directTaskSnapshot(task) });
403
+ return;
404
+ }
405
+ if (req.method === "POST" && url.pathname === "/v1/direct-execution/start") {
406
+ if (!acceptsNewWork(updateManager)) {
407
+ json(res, 503, updateInProgressResponse(updateManager));
408
+ return;
409
+ }
410
+ if (!isSessionValid(session, Date.now(), apiOrigin)) {
411
+ json(res, 401, { error: "simy session expired; run simy again" });
412
+ return;
413
+ }
414
+ const body = await readJson(req);
415
+ const instruction = String(body.instruction || "").trim();
416
+ if (!instruction) {
417
+ json(res, 400, { error: "instruction is required" });
418
+ return;
419
+ }
420
+ if (body.backend !== "codex" && body.backend !== "claude") {
421
+ json(res, 400, { error: "backend must be codex or claude" });
422
+ return;
423
+ }
424
+ const guardrail = classifyExecutionRequirement(instruction);
425
+ if (
426
+ guardrail.decision !== "direct_executor" ||
427
+ body.guardrail?.policy_version !== EXECUTION_GUARDRAIL_POLICY_VERSION ||
428
+ body.guardrail?.decision !== "direct_executor"
429
+ ) {
430
+ json(res, 409, {
431
+ error: "This request is not authorized for one-time direct execution.",
432
+ code: "DIRECT_EXECUTION_GUARD_REQUIRED",
433
+ guardrail,
434
+ });
435
+ return;
436
+ }
437
+ if (
438
+ body.operation !== guardrail.operation ||
439
+ body.permission_mode !== guardrail.permission_mode ||
440
+ body.max_provider_invocations !== 1
441
+ ) {
442
+ json(res, 409, {
443
+ error: "Direct execution permissions do not match the pre-guardrail decision.",
444
+ code: "DIRECT_EXECUTION_GUARD_MISMATCH",
445
+ guardrail,
446
+ });
447
+ return;
448
+ }
449
+ const inspection = normalizeBackendInspection(body.backend, availableCapabilities);
450
+ if (!inspection.compatible) {
451
+ const summary = backendPreflightSummary(body.backend, inspection);
452
+ json(res, 409, {
453
+ error: summary,
454
+ code: `desktop_executor_${inspection.status}`,
455
+ });
456
+ return;
457
+ }
458
+ const repository = String(body.repository || "").trim() || null;
459
+ if (guardrail.repository_required && !repository) {
460
+ json(res, 400, { error: "repository is required for this direct task" });
461
+ return;
462
+ }
463
+ const indexedRepository = repository ? await ensureRepositoryIndexed(repository) : null;
464
+ if (repository && !indexedRepository?.local_path) {
465
+ json(res, 409, {
466
+ error: `${repository} is not authorized on this SIMY CLI.`,
467
+ code: "repository_not_authorized",
468
+ });
469
+ return;
470
+ }
471
+ const repositoryPath = indexedRepository?.local_path || workspace.localPath;
472
+ if (!repositoryPath) {
473
+ json(res, 409, {
474
+ error: "No local workspace is available for this direct task.",
475
+ code: "local_workspace_unavailable",
476
+ });
477
+ return;
478
+ }
479
+ const task = createDirectTask({
480
+ instruction,
481
+ backend: body.backend,
482
+ operation: guardrail.operation,
483
+ permissionMode: guardrail.permission_mode,
484
+ repository,
485
+ repositoryPath,
486
+ });
487
+ directRegistry.create(task);
488
+ const runDirectTask = dependencies.runDirectTask || startDirectTask;
489
+ void Promise.resolve(runDirectTask(task)).catch((error) => {
490
+ task.status = "failed";
491
+ task.error = error instanceof Error ? error.message : String(error);
492
+ task.updated_at = new Date().toISOString();
493
+ });
494
+ json(res, 202, { ok: true, task: directTaskSnapshot(task) });
495
+ return;
496
+ }
379
497
  if (req.method === "POST" && agenticLoopPath === "/v1/agentic-loop/start") {
380
498
  if (!acceptsNewWork(updateManager)) {
381
499
  json(res, 503, updateInProgressResponse(updateManager));
@@ -386,6 +504,22 @@ export async function startAgent({
386
504
  return;
387
505
  }
388
506
  const { body, attachments } = await readCodingLoopStart(req);
507
+ const guardrailSource = String(body.source_message || "").trim();
508
+ if (guardrailSource) {
509
+ const guardrail = classifyExecutionRequirement(guardrailSource);
510
+ if (
511
+ guardrail.decision !== "agentic_loop" ||
512
+ body.guardrail?.policy_version !== EXECUTION_GUARDRAIL_POLICY_VERSION ||
513
+ body.guardrail?.decision !== "agentic_loop"
514
+ ) {
515
+ json(res, 409, {
516
+ error: "This task does not qualify for an Agentic Loop.",
517
+ code: "AGENTIC_LOOP_NOT_REQUIRED",
518
+ guardrail,
519
+ });
520
+ return;
521
+ }
522
+ }
389
523
  const verified = await verifyLaunchChallenge({
390
524
  apiOrigin,
391
525
  apiBaseUrl: session.api_base_url,
@@ -468,7 +602,7 @@ export async function startAgent({
468
602
  base_branch: typeof body.base_branch === "string" ? body.base_branch : "dev",
469
603
  max_attempts: body.max_attempts,
470
604
  retry_budget: body.retry_budget,
471
- token_budget: body.token_budget,
605
+ token_budget: body.provider_token_budget ?? body.token_budget,
472
606
  ui_evidence_root:
473
607
  typeof body.ui_evidence_root === "string" ? body.ui_evidence_root : "",
474
608
  acceptance_criteria: Array.isArray(body.acceptance_criteria)
@@ -553,6 +687,53 @@ export async function startAgent({
553
687
  return;
554
688
  }
555
689
 
690
+ const providerBudgetMatch = agenticLoopPath.match(
691
+ /^\/v1\/agentic-loop\/([^/]+)\/provider-token-budget$/,
692
+ );
693
+ if (req.method === "POST" && providerBudgetMatch) {
694
+ if (!acceptsNewWork(updateManager)) {
695
+ json(res, 503, updateInProgressResponse(updateManager));
696
+ return;
697
+ }
698
+ if (!isSessionValid(session, Date.now(), apiOrigin)) {
699
+ json(res, 401, { error: "simy session expired; run simy again" });
700
+ return;
701
+ }
702
+ const run = registry.get(decodeURIComponent(providerBudgetMatch[1]));
703
+ if (!run) {
704
+ json(res, 404, { error: "run not found" });
705
+ return;
706
+ }
707
+ const body = await readJson(req);
708
+ let budget;
709
+ try {
710
+ budget = await increaseLocalCodingRunProviderTokenBudget(
711
+ run,
712
+ body.provider_token_budget,
713
+ );
714
+ } catch (error) {
715
+ json(res, 409, {
716
+ error: error instanceof Error ? error.message : "Provider token budget was rejected",
717
+ code: "provider_token_budget_rejected",
718
+ });
719
+ return;
720
+ }
721
+ const continuation = continueLocalCodingRun(
722
+ run,
723
+ String(body.message || "Continue after increasing the Provider token budget."),
724
+ runOptions,
725
+ );
726
+ json(res, 202, {
727
+ ok: true,
728
+ run_id: run.id,
729
+ state: "resuming",
730
+ provider_token_budget: budget.token_budget,
731
+ provider_tokens_used: budget.tokens_used,
732
+ });
733
+ void continuation.catch((error) => reportProviderBudgetResumeError(error, quiet));
734
+ return;
735
+ }
736
+
556
737
  const guidanceMatch = agenticLoopPath.match(/^\/v1\/agentic-loop\/([^/]+)\/guidance$/);
557
738
  if (req.method === "POST" && guidanceMatch) {
558
739
  if (!isSessionValid(session, Date.now(), apiOrigin)) {
@@ -743,6 +924,7 @@ export async function startAgent({
743
924
  server,
744
925
  port,
745
926
  registry,
927
+ directRegistry,
746
928
  webOrigin: apiOrigin,
747
929
  get loginUrl() {
748
930
  return loginUrl?.toString() ?? null;
@@ -964,6 +1146,14 @@ function reportRepositoryResumeError(error, quiet) {
964
1146
  );
965
1147
  }
966
1148
 
1149
+ function reportProviderBudgetResumeError(error, quiet) {
1150
+ if (!quiet) {
1151
+ console.error(
1152
+ `Provider token budget was increased, but the run could not resume: ${error instanceof Error ? error.message : error}`,
1153
+ );
1154
+ }
1155
+ }
1156
+
967
1157
  function localRepositoryHilRequestId(run) {
968
1158
  return `local-repository-scan:${run.id}`;
969
1159
  }
@@ -1021,6 +1211,7 @@ async function capabilities() {
1021
1211
  repository_scan_approval: true,
1022
1212
  executor_version_preflight: true,
1023
1213
  desktop_executor: true,
1214
+ direct_execution: true,
1024
1215
  },
1025
1216
  session_ttl_hours: 48,
1026
1217
  };
@@ -1,4 +1,4 @@
1
- export const CLI_VERSION = "0.2.0";
1
+ export const CLI_VERSION = "0.2.2";
2
2
  export const CLI_API_CONTRACT_VERSION = 2;
3
3
 
4
4
  export function withCliContract(capabilities = {}, installMode = null) {
@@ -49,11 +49,18 @@ export async function resolveDesktopExecutorCommand({
49
49
  backend,
50
50
  instruction,
51
51
  repositoryPath,
52
+ executionKind = "agentic_loop",
53
+ permissionMode = "workspace_write",
52
54
  environment = process.env,
53
55
  spawnImpl = spawn,
54
56
  inspectionOptions = {},
55
57
  } = {}) {
56
58
  const provider = normalizeBackend(backend);
59
+ const directExecution = executionKind === "direct";
60
+ if (executionKind !== "agentic_loop" && !directExecution) {
61
+ throw new Error("Desktop execution kind must be agentic_loop or direct.");
62
+ }
63
+ const normalizedPermissionMode = normalizePermissionMode(permissionMode);
57
64
  const target = normalizeDesktopExecutionTarget(DESKTOP_EXECUTION_TARGET);
58
65
  const override =
59
66
  provider === "claude" ? environment.SIMY_CLAUDE_COMMAND : environment.SIMY_CODEX_COMMAND;
@@ -62,6 +69,8 @@ export async function resolveDesktopExecutorCommand({
62
69
  ...shellCommand(override, repositoryPath, instruction, spawnImpl),
63
70
  backend: provider,
64
71
  execution_target: target,
72
+ execution_kind: executionKind,
73
+ permission_mode: normalizedPermissionMode,
65
74
  verification: "explicit_command_override",
66
75
  };
67
76
  }
@@ -77,12 +86,24 @@ export async function resolveDesktopExecutorCommand({
77
86
  bin: inspection.executable,
78
87
  args:
79
88
  provider === "claude"
80
- ? claudeBackendArgs(instruction)
81
- : ["exec", "--json", instruction],
89
+ ? directExecution
90
+ ? claudeDirectBackendArgs(instruction, normalizedPermissionMode)
91
+ : claudeBackendArgs(instruction)
92
+ : directExecution
93
+ ? [
94
+ "exec",
95
+ "--json",
96
+ "--sandbox",
97
+ normalizedPermissionMode === "read_only" ? "read-only" : "workspace-write",
98
+ instruction,
99
+ ]
100
+ : ["exec", "--json", instruction],
82
101
  env: {},
83
102
  spawn: spawnImpl,
84
103
  backend: provider,
85
104
  execution_target: target,
105
+ execution_kind: executionKind,
106
+ permission_mode: normalizedPermissionMode,
86
107
  verification: "compatible_version",
87
108
  installed_version: inspection.installed_version,
88
109
  };
@@ -102,11 +123,30 @@ export function claudeBackendArgs(instruction) {
102
123
  ];
103
124
  }
104
125
 
126
+ export function claudeDirectBackendArgs(instruction, permissionMode = "read_only") {
127
+ const normalized = normalizePermissionMode(permissionMode);
128
+ return [
129
+ "-p",
130
+ instruction,
131
+ "--output-format",
132
+ "stream-json",
133
+ "--verbose",
134
+ ...(normalized === "read_only"
135
+ ? ["--permission-mode", "plan"]
136
+ : ["--dangerously-skip-permissions"]),
137
+ ];
138
+ }
139
+
105
140
  function normalizeBackend(backend) {
106
141
  if (backend === "codex" || backend === "claude") return backend;
107
142
  throw new Error("Desktop executor backend must be codex or claude.");
108
143
  }
109
144
 
145
+ function normalizePermissionMode(value) {
146
+ if (value === "read_only" || value === "workspace_write") return value;
147
+ throw new Error("Desktop permission mode must be read_only or workspace_write.");
148
+ }
149
+
110
150
  function desktopExecutorUnavailableError(inspection) {
111
151
  const provider = inspection.provider_label;
112
152
  let message;
@@ -131,6 +171,7 @@ function shellCommand(command, cwd, instruction, spawnImpl) {
131
171
  env: {
132
172
  SIMY_AGENTIC_LOOP_REQUIREMENT: instruction,
133
173
  SIMY_CODING_LOOP_REQUIREMENT: instruction,
174
+ SIMY_DIRECT_EXECUTION_INSTRUCTION: instruction,
134
175
  },
135
176
  spawn: spawnImpl,
136
177
  };
@@ -0,0 +1,142 @@
1
+ import { randomUUID } from "node:crypto";
2
+
3
+ import { resolveDesktopExecutorCommand } from "./desktop-executor.js";
4
+ import { createProviderStreamDecoder } from "./provider-stream.js";
5
+
6
+ const MAX_OUTPUT_LINES = 400;
7
+
8
+ export class DirectTaskRegistry {
9
+ #tasks = new Map();
10
+
11
+ create(task) {
12
+ if (this.#tasks.has(task.id)) throw new Error(`Direct task ${task.id} already exists.`);
13
+ this.#tasks.set(task.id, task);
14
+ return task;
15
+ }
16
+
17
+ get(taskId) {
18
+ return this.#tasks.get(taskId) ?? null;
19
+ }
20
+ }
21
+
22
+ export function createDirectTask({
23
+ instruction,
24
+ backend,
25
+ operation,
26
+ permissionMode,
27
+ repository = null,
28
+ repositoryPath,
29
+ }) {
30
+ const now = new Date().toISOString();
31
+ return {
32
+ id: `direct_task_${randomUUID()}`,
33
+ status: "queued",
34
+ instruction,
35
+ backend,
36
+ operation,
37
+ permission_mode: permissionMode,
38
+ repository,
39
+ repository_path: repositoryPath,
40
+ invocation_count: 0,
41
+ output: [],
42
+ result: null,
43
+ error: null,
44
+ token_usage: { records: [] },
45
+ created_at: now,
46
+ updated_at: now,
47
+ child: null,
48
+ };
49
+ }
50
+
51
+ export async function startDirectTask(
52
+ task,
53
+ { resolveCommand = resolveDesktopExecutorCommand, environment = process.env } = {},
54
+ ) {
55
+ if (!task || task.status !== "queued") throw new Error("Direct task must be queued.");
56
+ if (task.invocation_count >= 1) throw new Error("Direct tasks allow only one Provider invocation.");
57
+ const command = await resolveCommand({
58
+ backend: task.backend,
59
+ instruction: task.instruction,
60
+ repositoryPath: task.repository_path,
61
+ executionKind: "direct",
62
+ permissionMode: task.permission_mode,
63
+ environment,
64
+ });
65
+ task.status = "running";
66
+ task.invocation_count = 1;
67
+ task.updated_at = new Date().toISOString();
68
+
69
+ return new Promise((resolve) => {
70
+ let settled = false;
71
+ const rawStdout = [];
72
+ const tokenRecords = [];
73
+ const child = command.spawn(command.bin, command.args, {
74
+ cwd: task.repository_path,
75
+ env: { ...process.env, ...command.env },
76
+ stdio: ["ignore", "pipe", "pipe"],
77
+ detached: process.platform !== "win32",
78
+ });
79
+ task.child = child;
80
+ const onLine = (line) => {
81
+ const normalized = String(line || "").trim();
82
+ if (!normalized) return;
83
+ task.output.push(normalized);
84
+ if (task.output.length > MAX_OUTPUT_LINES) {
85
+ task.output.splice(0, task.output.length - MAX_OUTPUT_LINES);
86
+ }
87
+ task.updated_at = new Date().toISOString();
88
+ };
89
+ const stdoutDecoder = createProviderStreamDecoder({
90
+ backend: task.backend,
91
+ stream: "stdout",
92
+ onLine,
93
+ onUsage: (usage) => tokenRecords.push({ ...usage, phase: "direct", backend: task.backend }),
94
+ });
95
+ const stderrDecoder = createProviderStreamDecoder({
96
+ backend: task.backend,
97
+ stream: "stderr",
98
+ onLine,
99
+ });
100
+ child.stdout?.on("data", (chunk) => {
101
+ const text = chunk.toString("utf8");
102
+ rawStdout.push(text);
103
+ stdoutDecoder.push(text);
104
+ });
105
+ child.stderr?.on("data", (chunk) => stderrDecoder.push(chunk.toString("utf8")));
106
+
107
+ const finish = ({ exitCode = null, error = null } = {}) => {
108
+ if (settled) return;
109
+ settled = true;
110
+ stdoutDecoder.flush();
111
+ stderrDecoder.flush();
112
+ task.child = null;
113
+ task.token_usage = { records: tokenRecords };
114
+ task.status = error || exitCode !== 0 ? "failed" : "succeeded";
115
+ task.error = error || (exitCode !== 0 ? `Provider exited with code ${exitCode}.` : null);
116
+ task.result = task.output.at(-1) || rawStdout.join("").trim() || null;
117
+ task.updated_at = new Date().toISOString();
118
+ resolve(task);
119
+ };
120
+ child.on("error", (error) => finish({ error: error.message }));
121
+ child.on("close", (code) => finish({ exitCode: code }));
122
+ });
123
+ }
124
+
125
+ export function directTaskSnapshot(task) {
126
+ return {
127
+ id: task.id,
128
+ status: task.status,
129
+ instruction: task.instruction,
130
+ backend: task.backend,
131
+ operation: task.operation,
132
+ permission_mode: task.permission_mode,
133
+ repository: task.repository,
134
+ invocation_count: task.invocation_count,
135
+ output: [...task.output],
136
+ result: task.result,
137
+ error: task.error,
138
+ token_usage: structuredClone(task.token_usage),
139
+ created_at: task.created_at,
140
+ updated_at: task.updated_at,
141
+ };
142
+ }
@@ -0,0 +1,120 @@
1
+ export const EXECUTION_GUARDRAIL_POLICY_VERSION = "2026-07-21.1";
2
+
3
+ const OBSERVE_PATTERN =
4
+ /\b(monitor|watch|observe|track|status|progress|keep an eye on)\b|監視|見守|進捗|状況を?(?:見|確認)|监控|監控|观察|觀察|进度|進度|状态|狀態/i;
5
+ const READ_PATTERN =
6
+ /\b(explain|summari[sz]e|inspect|review|look at|read|check|diagnose|investigate)\b|説明|要約|確認|調査|レビュー|查看|解释|說明|总结|總結|检查|檢查|调查|調查|诊断|診斷/i;
7
+ const CHANGE_PATTERN =
8
+ /\b(implement|fix|build|create|change|update|refactor|add|remove|rename|ship|write|edit|merge|release|publish)\b|実装|修正|改善|変更|追加|削除|作成|開発|マージ|公開|发布|發佈|实现|實現|修复|修復|修改|改掉|改一下|新增|删除|刪除|重构|重構|合并|合併/i;
9
+ const OBJECT_PATTERN =
10
+ /\b(code|file|readme|api|ui|ux|feature|bug|pr|pull request|repository|repo|branch|test|migration|endpoint|component|page|cli|backend|extension)\b|コード|ファイル|機能|不具合|画面|ページ|テスト|移行|组件|組件|页面|頁面|代码|代碼|文件|功能|错误|錯誤|仓库|倉庫|分支/i;
11
+ const VAGUE_PATTERN = /^(?:fix|do|handle|continue|change)?\s*(?:it|this|that)?[.!。!]?$/i;
12
+ const VAGUE_CJK_PATTERN = /^(?:これ|それ|あれ|やって|直して|続けて|这个|這個|那个|那個|处理|處理|修一下)[。!]?$/i;
13
+ const UNSUPPORTED_PATTERN =
14
+ /\b(?:steal|exfiltrate|bypass authentication|disable audit)\b|認証を迂回|監査を無効|窃取凭证|繞過認證/i;
15
+
16
+ const MANAGED_SIGNAL_PATTERNS = [
17
+ ["pr_merge_release", /\b(?:pull request|pr|merge|release|publish|deploy)\b|PR|マージ|リリース|デプロイ|合并|合併|发布|發佈|部署/i],
18
+ ["verification", /\b(?:test|e2e|integration test|until green|ci)\b|テスト|検証|通过测试|通過測試|直到.*通过|直到.*通過/i],
19
+ ["browser_evidence", /\b(?:browser|screenshots?|visual evidence|evidence)\b|ブラウザ|スクリーンショット|証跡|截图|截圖|浏览器|瀏覽器|证据|證據/i],
20
+ ["cross_repository", /\b(?:cross[- ]repo|multiple repos|multi[- ]repo)\b|複数(?:の)?リポジトリ|跨仓库|跨倉庫|多个仓库|多個倉庫/i],
21
+ ["high_risk_change", /\b(?:migration|authentication|authorization|security|rollback)\b|移行|認証|認可|セキュリティ|ロールバック|迁移|遷移|认证|認證|授权|授權|安全|回滚|回滾/i],
22
+ ["diagnose_repair_reverify", /\bdiagnos\w*\b[\s\S]*\bfix\w*\b[\s\S]*\b(?:verify|retest)\b|調査[\s\S]*修正[\s\S]*再検証|诊断[\s\S]*修复[\s\S]*复验|診斷[\s\S]*修復[\s\S]*複驗/i],
23
+ ];
24
+
25
+ export function classifyExecutionRequirement(sourceMessage) {
26
+ const source = String(sourceMessage || "")
27
+ .replace(/^\s*\/(?:agentic-loop|coding-loop)\b\s*/i, "")
28
+ .trim();
29
+ if (!source || VAGUE_PATTERN.test(source) || VAGUE_CJK_PATTERN.test(source)) {
30
+ const vagueChangeRequested = CHANGE_PATTERN.test(source);
31
+ return guardrail("needs_clarification", {
32
+ change_requested: vagueChangeRequested,
33
+ operation: vagueChangeRequested ? "execute" : "read",
34
+ permission_mode: vagueChangeRequested ? "workspace_write" : "read_only",
35
+ reason_code: "goal_not_clear",
36
+ });
37
+ }
38
+ if (UNSUPPORTED_PATTERN.test(source)) {
39
+ return guardrail("unsupported", {
40
+ operation: "execute",
41
+ permission_mode: "workspace_write",
42
+ reason_code: "prohibited_operation",
43
+ });
44
+ }
45
+ const changeRequested = CHANGE_PATTERN.test(source);
46
+ const observeRequested = OBSERVE_PATTERN.test(source);
47
+ const readRequested = READ_PATTERN.test(source);
48
+ const hasObject = OBJECT_PATTERN.test(source) || source.length >= 24;
49
+ const agenticSignals = MANAGED_SIGNAL_PATTERNS.filter(([, pattern]) => pattern.test(source)).map(
50
+ ([signal]) => signal,
51
+ );
52
+ if (changeRequested && hasObject && agenticSignals.length >= 2) {
53
+ return guardrail("agentic_loop", {
54
+ change_requested: true,
55
+ managed_lifecycle_required: true,
56
+ repository_required: true,
57
+ operation: "execute",
58
+ permission_mode: "workspace_write",
59
+ agentic_signals: agenticSignals,
60
+ reason_code: "managed_change_lifecycle_required",
61
+ });
62
+ }
63
+ if (observeRequested && !changeRequested) {
64
+ return guardrail("direct_executor", {
65
+ repository_required: /\b(?:repo|repository|pr|branch)\b|リポジトリ|仓库|倉庫|分支/i.test(source),
66
+ operation: "observe",
67
+ permission_mode: "read_only",
68
+ direct_signals: ["bounded_observation", "no_persistent_change"],
69
+ reason_code: "observe_existing_provider_session",
70
+ });
71
+ }
72
+ if (changeRequested && hasObject) {
73
+ return guardrail("direct_executor", {
74
+ change_requested: true,
75
+ repository_required: true,
76
+ operation: "execute",
77
+ permission_mode: "workspace_write",
78
+ agentic_signals: agenticSignals,
79
+ direct_signals: ["single_bounded_change", "managed_lifecycle_not_required"],
80
+ reason_code: "bounded_change_direct_execution",
81
+ });
82
+ }
83
+ if (changeRequested) {
84
+ return guardrail("needs_clarification", {
85
+ change_requested: true,
86
+ operation: "execute",
87
+ permission_mode: "workspace_write",
88
+ reason_code: "change_target_missing",
89
+ });
90
+ }
91
+ if (readRequested || source.length >= 8) {
92
+ return guardrail("direct_executor", {
93
+ repository_required: /\b(?:repo|repository|pr|file|code)\b|コード|ファイル|仓库|倉庫|文件|代码|代碼/i.test(source),
94
+ operation: "read",
95
+ permission_mode: "read_only",
96
+ direct_signals: ["single_read_task", "no_managed_lifecycle"],
97
+ reason_code: "bounded_read_direct_execution",
98
+ });
99
+ }
100
+ return guardrail("needs_clarification", {
101
+ operation: "read",
102
+ permission_mode: "read_only",
103
+ reason_code: "action_missing",
104
+ });
105
+ }
106
+
107
+ function guardrail(decision, overrides) {
108
+ return {
109
+ policy_version: EXECUTION_GUARDRAIL_POLICY_VERSION,
110
+ decision,
111
+ change_requested: false,
112
+ managed_lifecycle_required: false,
113
+ repository_required: false,
114
+ operation: "read",
115
+ permission_mode: "read_only",
116
+ agentic_signals: [],
117
+ direct_signals: [],
118
+ ...overrides,
119
+ };
120
+ }
@@ -2,7 +2,8 @@ import { clampAttempts } from "./shared.js";
2
2
 
3
3
  export const DEFAULT_TOKEN_BUDGET = 250_000;
4
4
  export const DEFAULT_RETRY_BUDGET = 2;
5
- const MAX_TOKEN_BUDGET = 10_000_000;
5
+ export const MAX_PROVIDER_TOKEN_BUDGET = 10_000_000;
6
+ const PROVIDER_TOKEN_BUDGET_STEP = 250_000;
6
7
 
7
8
  export function resolveRunBudgets(request = {}) {
8
9
  const legacyMaxAttempts = clampAttempts(request.max_attempts);
@@ -12,12 +13,47 @@ export function resolveRunBudgets(request = {}) {
12
13
  ? legacyMaxAttempts - 1
13
14
  : Math.min(4, Math.max(0, explicitRetryBudget));
14
15
  return {
15
- token_budget: normalizeTokenBudget(request.token_budget),
16
+ token_budget: normalizeTokenBudget(request.provider_token_budget ?? request.token_budget),
16
17
  retry_budget: retryBudget,
17
18
  max_attempts: retryBudget + 1,
18
19
  };
19
20
  }
20
21
 
22
+ export function suggestedProviderTokenBudget(value) {
23
+ const state = value?.schema_version === 1 ? value : budgetState(value);
24
+ if (state.tokens_used >= MAX_PROVIDER_TOKEN_BUDGET) return null;
25
+ const required = Math.max(
26
+ state.token_budget * 2,
27
+ state.tokens_used * 1.25,
28
+ state.tokens_used + PROVIDER_TOKEN_BUDGET_STEP,
29
+ );
30
+ return Math.min(
31
+ MAX_PROVIDER_TOKEN_BUDGET,
32
+ Math.ceil(required / PROVIDER_TOKEN_BUDGET_STEP) * PROVIDER_TOKEN_BUDGET_STEP,
33
+ );
34
+ }
35
+
36
+ export function increaseProviderTokenBudget(snapshot, value) {
37
+ const requested = integer(value);
38
+ if (requested === null || requested <= 0) {
39
+ throw new Error("Provider token budget must be a positive integer.");
40
+ }
41
+ if (requested > MAX_PROVIDER_TOKEN_BUDGET) {
42
+ throw new Error(
43
+ `Provider token budget cannot exceed ${MAX_PROVIDER_TOKEN_BUDGET.toLocaleString("en-US")}.`,
44
+ );
45
+ }
46
+ const current = budgetState(snapshot);
47
+ if (requested <= current.token_budget) {
48
+ throw new Error("Provider token budget must be higher than the current limit.");
49
+ }
50
+ if (requested <= current.tokens_used) {
51
+ throw new Error("Provider token budget must be higher than the Provider tokens already used.");
52
+ }
53
+ snapshot.charter.token_budget = requested;
54
+ return refreshBudgetState(snapshot);
55
+ }
56
+
21
57
  export function budgetState(snapshot, pendingAttempt = null) {
22
58
  const attempts = [
23
59
  ...(Array.isArray(snapshot?.attempts) ? snapshot.attempts : []),
@@ -93,7 +129,7 @@ function recordTokenTotal(value) {
93
129
  function normalizeTokenBudget(value) {
94
130
  const parsed = integer(value);
95
131
  if (parsed === null || parsed <= 0) return DEFAULT_TOKEN_BUDGET;
96
- return Math.min(MAX_TOKEN_BUDGET, parsed);
132
+ return Math.min(MAX_PROVIDER_TOKEN_BUDGET, parsed);
97
133
  }
98
134
 
99
135
  function normalizeRetryBudget(value, maxAttempts) {
@@ -469,7 +469,7 @@ async function stopForBudget({ snapshot, attempt, reason, onUpdate }) {
469
469
  if (attempt) attempt.budget = budget;
470
470
  const message =
471
471
  reason === "token_budget_exhausted"
472
- ? "Agentic loop exhausted its token budget."
472
+ ? "Agentic loop reached its Provider token budget."
473
473
  : "Agentic loop exhausted its retry budget.";
474
474
  appendEvent(snapshot, "blocked", message, {
475
475
  code: reason,
@@ -487,8 +487,8 @@ function budgetFinding(reason, budget) {
487
487
  passed: false,
488
488
  code: "TOKEN_BUDGET_EXHAUSTED",
489
489
  severity: "blocker",
490
- target: "token_budget",
491
- explanation: `The run used ${budget.tokens_used} of ${budget.token_budget} provider tokens, so no additional executor or reviewer call can start.`,
490
+ target: "provider_token_budget",
491
+ explanation: `The run used ${budget.tokens_used} of ${budget.token_budget} Provider tokens, so no additional executor or reviewer call can start until the Provider token budget is increased. Provider tokens are not billed as SIMY tokens.`,
492
492
  repairability: "manual",
493
493
  auto_fix_hint: null,
494
494
  };
@@ -1,3 +1,5 @@
1
+ import { suggestedProviderTokenBudget } from "./budget.js";
2
+
1
3
  const RECOVERABLE_STATES = new Set(["waiting_human", "blocked", "failed"]);
2
4
 
3
5
  export function recoveryContractForEvent({ snapshot, state, message, detail = {} }) {
@@ -6,7 +8,7 @@ export function recoveryContractForEvent({ snapshot, state, message, detail = {}
6
8
 
7
9
  const reasonCode = recoveryReasonCode(state, message, detail);
8
10
  const attemptNumber = positiveInteger(detail.attempt_number);
9
- const actions = recoveryActions(reasonCode);
11
+ const actions = recoveryActions(reasonCode, detail.budget);
10
12
  return {
11
13
  schema_version: 1,
12
14
  state,
@@ -18,6 +20,8 @@ export function recoveryContractForEvent({ snapshot, state, message, detail = {}
18
20
  event_code: clean(detail.code) || null,
19
21
  attempt_number: attemptNumber,
20
22
  max_attempts: positiveInteger(snapshot?.charter?.max_attempts),
23
+ provider_token_budget: positiveInteger(snapshot?.charter?.token_budget),
24
+ provider_tokens_used: nonNegativeInteger(detail?.budget?.tokens_used),
21
25
  token_budget: positiveInteger(snapshot?.charter?.token_budget),
22
26
  retry_budget: nonNegativeInteger(snapshot?.charter?.retry_budget),
23
27
  tokens_used: nonNegativeInteger(detail?.budget?.tokens_used),
@@ -136,7 +140,7 @@ function recoveryMessage(reasonCode) {
136
140
  "SIMY needs the missing requirement or approval before implementation can start.",
137
141
  human_input_required: "SIMY needs your decision or clarification before it can continue.",
138
142
  token_budget_exhausted:
139
- "This run used its token budget. Start a new run if you want SIMY to continue with a fresh limit.",
143
+ "This run reached its Provider token safety limit. Provider tokens are not billed by SIMY. Increase the limit to continue this run.",
140
144
  retry_budget_exhausted:
141
145
  "This run used its automatic retry budget. Start a new run to continue with a fresh limit.",
142
146
  automatic_progress_blocked:
@@ -149,7 +153,7 @@ function recoveryMessage(reasonCode) {
149
153
  return messages[reasonCode] || messages.automatic_progress_blocked;
150
154
  }
151
155
 
152
- function recoveryActions(reasonCode) {
156
+ function recoveryActions(reasonCode, budget = null) {
153
157
  if (reasonCode === "repository_authorization_required") {
154
158
  return [
155
159
  {
@@ -182,7 +186,25 @@ function recoveryActions(reasonCode) {
182
186
  newRunAction(),
183
187
  ];
184
188
  }
185
- if (reasonCode === "token_budget_exhausted" || reasonCode === "retry_budget_exhausted") {
189
+ if (reasonCode === "token_budget_exhausted") {
190
+ const suggestedBudget = suggestedProviderTokenBudget(budget);
191
+ if (!suggestedBudget || budget?.retry_exhausted === true) return [newRunAction()];
192
+ return [
193
+ {
194
+ id: "increase_provider_token_budget",
195
+ type: "increase_provider_token_budget",
196
+ label: "Increase Provider token budget",
197
+ description:
198
+ "Raise this run's safety limit and continue. Provider tokens are usage diagnostics and are not billed as SIMY tokens.",
199
+ command: null,
200
+ payload: {
201
+ ...(suggestedBudget ? { suggested_provider_token_budget: suggestedBudget } : {}),
202
+ },
203
+ },
204
+ newRunAction(),
205
+ ];
206
+ }
207
+ if (reasonCode === "retry_budget_exhausted") {
186
208
  return [newRunAction()];
187
209
  }
188
210
  if (reasonCode === "local_execution_interrupted") {
package/src/runner.js CHANGED
@@ -19,7 +19,7 @@ import {
19
19
  redactExecutionText,
20
20
  } from "./orchestrator/execution-io.js";
21
21
  import { summarizeCodingLoopEvent } from "./orchestrator/presentation.js";
22
- import { refreshBudgetState } from "./orchestrator/budget.js";
22
+ import { increaseProviderTokenBudget, refreshBudgetState } from "./orchestrator/budget.js";
23
23
  import { appendEvent } from "./orchestrator/shared.js";
24
24
  import {
25
25
  createProviderStreamDecoder,
@@ -252,6 +252,35 @@ export async function continueLocalCodingRun(run, guidance, dependencies = {}) {
252
252
  return executeLocalCodingRun(run, dependencies, { humanGuidance: message, resume: true });
253
253
  }
254
254
 
255
+ export async function increaseLocalCodingRunProviderTokenBudget(run, value) {
256
+ if (run.operation || run.child) {
257
+ throw new Error("Provider token budget can change only while the run is waiting.");
258
+ }
259
+ if (!RESUMABLE_STATES.has(run.status)) {
260
+ throw new Error("Provider token budget can change only for a blocked or waiting run.");
261
+ }
262
+ const currentBudget = refreshBudgetState(run.snapshot);
263
+ if (currentBudget.retry_exhausted) {
264
+ throw new Error("The Agentic Loop run also used its retry budget. Start a new run to continue.");
265
+ }
266
+ const previousBudget = currentBudget.token_budget;
267
+ const budget = increaseProviderTokenBudget(run.snapshot, value);
268
+ run.request.token_budget = budget.token_budget;
269
+ appendEvent(
270
+ run.snapshot,
271
+ "waiting_human",
272
+ `Provider token budget increased from ${previousBudget} to ${budget.token_budget}.`,
273
+ {
274
+ code: "provider_token_budget_increased",
275
+ previous_provider_token_budget: previousBudget,
276
+ provider_token_budget: budget.token_budget,
277
+ budget,
278
+ },
279
+ );
280
+ await updateRun(run, "waiting_human");
281
+ return budget;
282
+ }
283
+
255
284
  export async function continueLocalCodingRunAfterRepositoryApproval(
256
285
  run,
257
286
  { repository, localPath } = {},