@awak-app/simy-cli 0.1.5 → 0.2.1

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
@@ -93,6 +93,19 @@ executor updates, repository selection, and CLI reconnection. Web clients must
93
93
  validate the action type before rendering or executing it; raw local error text
94
94
  is not copied into the recovery contract.
95
95
 
96
+ ## Provider token budget
97
+
98
+ Each run accepts a configurable `token_budget` (or the explicit
99
+ `provider_token_budget` alias) from 1 to 10,000,000 Provider tokens. The default
100
+ is 250,000. Provider tokens are the Codex or Claude Code usage reported for the
101
+ run; they are a safety limit and are not billed as SIMY tokens.
102
+
103
+ When a run reaches this limit, its recovery contract suggests a higher rounded
104
+ limit. SIMY Web can edit that suggestion and call
105
+ `POST /v1/agentic-loop/:run_id/provider-token-budget` with
106
+ `provider_token_budget`. The CLI persists the higher limit and resumes the same
107
+ run. SIMY token consumption remains a separate platform billing record.
108
+
96
109
  ## Interactive console
97
110
 
98
111
  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.1.5",
3
+ "version": "0.2.1",
4
4
  "description": "Local SIMY Agentic Loop executor for Codex and Claude Code.",
5
5
  "type": "module",
6
6
  "bin": {
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,
@@ -64,6 +65,11 @@ import {
64
65
  scanGitRepositories,
65
66
  writeRepositoryInventory,
66
67
  } from "./repository-inventory.js";
68
+ import {
69
+ CLI_API_CONTRACT_VERSION,
70
+ CLI_VERSION,
71
+ withCliContract,
72
+ } from "./cli-contract.js";
67
73
 
68
74
  const DEVICE_HEARTBEAT_INTERVAL_MS = 20_000;
69
75
 
@@ -99,7 +105,11 @@ export async function startAgent({
99
105
  ]
100
106
  : [],
101
107
  );
102
- const availableCapabilities = await readCapabilities(dependencies);
108
+ const installMode = updateManager?.snapshot?.()?.install_mode ?? null;
109
+ const availableCapabilities = withCliContract(
110
+ await readCapabilities(dependencies),
111
+ installMode,
112
+ );
103
113
  const heartbeatDevice = dependencies.heartbeatDevice || heartbeatLocalDevice;
104
114
  const runOptions = dependencies.runOptions || {};
105
115
  const repositoryScanRoot = resolve(
@@ -274,6 +284,8 @@ export async function startAgent({
274
284
  if (req.method === "GET" && url.pathname === "/v1/health") {
275
285
  json(res, 200, {
276
286
  ok: true,
287
+ cli_version: CLI_VERSION,
288
+ api_contract_version: CLI_API_CONTRACT_VERSION,
277
289
  daemon,
278
290
  web_origin: apiOrigin,
279
291
  session_valid: isSessionValid(session, Date.now(), apiOrigin),
@@ -283,7 +295,7 @@ export async function startAgent({
283
295
  return;
284
296
  }
285
297
  if (req.method === "GET" && url.pathname === "/v1/capabilities") {
286
- json(res, 200, await readCapabilities(dependencies));
298
+ json(res, 200, availableCapabilities);
287
299
  return;
288
300
  }
289
301
  if (req.method === "POST" && agenticLoopPath === "/v1/agentic-loop/preflight") {
@@ -356,7 +368,12 @@ export async function startAgent({
356
368
  startHeartbeatTimer();
357
369
  loginUrl = null;
358
370
  registry.emit("change", registry.list());
359
- json(res, 200, { ok: true, expires_at: session.expires_at });
371
+ json(res, 200, {
372
+ ok: true,
373
+ expires_at: session.expires_at,
374
+ cli_version: CLI_VERSION,
375
+ api_contract_version: CLI_API_CONTRACT_VERSION,
376
+ });
360
377
  void synchronizeAuthorizedSession();
361
378
  return;
362
379
  }
@@ -452,7 +469,7 @@ export async function startAgent({
452
469
  base_branch: typeof body.base_branch === "string" ? body.base_branch : "dev",
453
470
  max_attempts: body.max_attempts,
454
471
  retry_budget: body.retry_budget,
455
- token_budget: body.token_budget,
472
+ token_budget: body.provider_token_budget ?? body.token_budget,
456
473
  ui_evidence_root:
457
474
  typeof body.ui_evidence_root === "string" ? body.ui_evidence_root : "",
458
475
  acceptance_criteria: Array.isArray(body.acceptance_criteria)
@@ -537,6 +554,53 @@ export async function startAgent({
537
554
  return;
538
555
  }
539
556
 
557
+ const providerBudgetMatch = agenticLoopPath.match(
558
+ /^\/v1\/agentic-loop\/([^/]+)\/provider-token-budget$/,
559
+ );
560
+ if (req.method === "POST" && providerBudgetMatch) {
561
+ if (!acceptsNewWork(updateManager)) {
562
+ json(res, 503, updateInProgressResponse(updateManager));
563
+ return;
564
+ }
565
+ if (!isSessionValid(session, Date.now(), apiOrigin)) {
566
+ json(res, 401, { error: "simy session expired; run simy again" });
567
+ return;
568
+ }
569
+ const run = registry.get(decodeURIComponent(providerBudgetMatch[1]));
570
+ if (!run) {
571
+ json(res, 404, { error: "run not found" });
572
+ return;
573
+ }
574
+ const body = await readJson(req);
575
+ let budget;
576
+ try {
577
+ budget = await increaseLocalCodingRunProviderTokenBudget(
578
+ run,
579
+ body.provider_token_budget,
580
+ );
581
+ } catch (error) {
582
+ json(res, 409, {
583
+ error: error instanceof Error ? error.message : "Provider token budget was rejected",
584
+ code: "provider_token_budget_rejected",
585
+ });
586
+ return;
587
+ }
588
+ const continuation = continueLocalCodingRun(
589
+ run,
590
+ String(body.message || "Continue after increasing the Provider token budget."),
591
+ runOptions,
592
+ );
593
+ json(res, 202, {
594
+ ok: true,
595
+ run_id: run.id,
596
+ state: "resuming",
597
+ provider_token_budget: budget.token_budget,
598
+ provider_tokens_used: budget.tokens_used,
599
+ });
600
+ void continuation.catch((error) => reportProviderBudgetResumeError(error, quiet));
601
+ return;
602
+ }
603
+
540
604
  const guidanceMatch = agenticLoopPath.match(/^\/v1\/agentic-loop\/([^/]+)\/guidance$/);
541
605
  if (req.method === "POST" && guidanceMatch) {
542
606
  if (!isSessionValid(session, Date.now(), apiOrigin)) {
@@ -698,6 +762,9 @@ export async function startAgent({
698
762
  loginUrl = new URL("/local-cli/connect", apiOrigin);
699
763
  loginUrl.searchParams.set("port", String(port));
700
764
  loginUrl.searchParams.set("nonce", authNonce);
765
+ loginUrl.searchParams.set("cli_version", CLI_VERSION);
766
+ loginUrl.searchParams.set("api_contract_version", String(CLI_API_CONTRACT_VERSION));
767
+ if (installMode) loginUrl.searchParams.set("install_mode", installMode);
701
768
  if (!quiet) {
702
769
  console.log(`Sign in to SIMY: ${loginUrl.toString()}`);
703
770
  console.log(`Session file: ${sessionPath(apiOrigin, sessionRoot)}`);
@@ -910,6 +977,8 @@ export async function heartbeatLocalDevice({
910
977
  ),
911
978
  body: JSON.stringify({
912
979
  port,
980
+ cli_version: CLI_VERSION,
981
+ api_contract_version: CLI_API_CONTRACT_VERSION,
913
982
  capabilities,
914
983
  repo_inventory: repoInventory,
915
984
  }),
@@ -943,6 +1012,14 @@ function reportRepositoryResumeError(error, quiet) {
943
1012
  );
944
1013
  }
945
1014
 
1015
+ function reportProviderBudgetResumeError(error, quiet) {
1016
+ if (!quiet) {
1017
+ console.error(
1018
+ `Provider token budget was increased, but the run could not resume: ${error instanceof Error ? error.message : error}`,
1019
+ );
1020
+ }
1021
+ }
1022
+
946
1023
  function localRepositoryHilRequestId(run) {
947
1024
  return `local-repository-scan:${run.id}`;
948
1025
  }
@@ -0,0 +1,11 @@
1
+ export const CLI_VERSION = "0.2.1";
2
+ export const CLI_API_CONTRACT_VERSION = 2;
3
+
4
+ export function withCliContract(capabilities = {}, installMode = null) {
5
+ return {
6
+ ...capabilities,
7
+ cli_version: CLI_VERSION,
8
+ api_contract_version: CLI_API_CONTRACT_VERSION,
9
+ ...(installMode ? { install_mode: installMode } : {}),
10
+ };
11
+ }
@@ -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 } = {},