@awak-app/simy-cli 0.5.34 → 0.5.36

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/src/local-task.js CHANGED
@@ -19,6 +19,7 @@ import path from "node:path";
19
19
  import { promisify } from "node:util";
20
20
 
21
21
  import { resolveDesktopExecutorCommand } from "./desktop-executor.js";
22
+ import { providerBudgetTokens } from "./orchestrator/budget.js";
22
23
  import {
23
24
  buildLocalTaskArtifactRefs,
24
25
  MAX_LOCAL_TASK_ARTIFACT_REFS,
@@ -41,9 +42,13 @@ import { signalExecutorProcess } from "./runner.js";
41
42
  import {
42
43
  createWorkflowObservationSender,
43
44
  normalizeWorkflowObservationBinding,
45
+ normalizeWorkflowStartIntent,
44
46
  } from "./workflow-observation-sender.js";
47
+ import { uploadWorkflowDeliverables } from "./workflow-deliverable-upload.js";
45
48
 
46
- export const DEFAULT_LOCAL_TASK_TOKEN_BUDGET = 100_000;
49
+ // Weighted tokens (cache reads count at 1/10). A real Claude Code WorkFlow step with the
50
+ // Purpose Charter context measured ~200k weighted tokens on dev, so 100k failed every run.
51
+ export const DEFAULT_LOCAL_TASK_TOKEN_BUDGET = 500_000;
47
52
  export const DEFAULT_LOCAL_TASK_TIMEOUT_MS = 10 * 60_000;
48
53
  export const MAX_LOCAL_TASK_TOKEN_BUDGET = 10_000_000;
49
54
  export const MAX_LOCAL_TASK_TIMEOUT_MS = 60 * 60_000;
@@ -256,6 +261,7 @@ export function createLocalTask({
256
261
  idempotencyKeyHash = null,
257
262
  requestFingerprint = null,
258
263
  workflowObservationBinding = null,
264
+ workflowStartIntent = null,
259
265
  }) {
260
266
  validateTaskId(id);
261
267
  const now = new Date().toISOString();
@@ -268,6 +274,13 @@ export function createLocalTask({
268
274
  workflowObservationBinding,
269
275
  { provider: backend },
270
276
  );
277
+ const normalizedWorkflowStartIntent = normalizeWorkflowStartIntent(
278
+ workflowStartIntent,
279
+ { provider: backend },
280
+ );
281
+ if (normalizedWorkflowObservationBinding && normalizedWorkflowStartIntent) {
282
+ throw new Error("WorkFlow binding and start intent are mutually exclusive.");
283
+ }
271
284
  return {
272
285
  id,
273
286
  kind: "local_task",
@@ -316,6 +329,17 @@ export function createLocalTask({
316
329
  },
317
330
  }
318
331
  : {}),
332
+ ...(normalizedWorkflowStartIntent
333
+ ? {
334
+ workflow_start_intent: normalizedWorkflowStartIntent,
335
+ workflow_observation: {
336
+ status: "pending",
337
+ workflow_id: normalizedWorkflowStartIntent.workflow_id,
338
+ workflow_run_id: null,
339
+ development_outcome: "unverified",
340
+ },
341
+ }
342
+ : {}),
319
343
  timeout_ms: normalizedTimeout,
320
344
  created_at: now,
321
345
  updated_at: now,
@@ -348,6 +372,7 @@ export function createLocalTaskRequestFingerprint({
348
372
  attachmentManifest,
349
373
  scenarioPack,
350
374
  workflowObservationBinding = null,
375
+ workflowStartIntent = null,
351
376
  }) {
352
377
  const normalizedModel = normalizeLocalTaskModel(model);
353
378
  const manifest = Array.isArray(attachmentManifest)
@@ -364,6 +389,13 @@ export function createLocalTaskRequestFingerprint({
364
389
  workflowObservationBinding,
365
390
  { provider: backend },
366
391
  );
392
+ const normalizedWorkflowStartIntent = normalizeWorkflowStartIntent(
393
+ workflowStartIntent,
394
+ { provider: backend },
395
+ );
396
+ if (normalizedWorkflowObservationBinding && normalizedWorkflowStartIntent) {
397
+ throw new Error("WorkFlow binding and start intent are mutually exclusive.");
398
+ }
367
399
  const payload = {
368
400
  instruction: String(instruction || "").trim(),
369
401
  backend: String(backend || ""),
@@ -380,6 +412,9 @@ export function createLocalTaskRequestFingerprint({
380
412
  ...(normalizedWorkflowObservationBinding
381
413
  ? { workflow_observation_binding: normalizedWorkflowObservationBinding }
382
414
  : {}),
415
+ ...(normalizedWorkflowStartIntent
416
+ ? { workflow_start_intent: normalizedWorkflowStartIntent }
417
+ : {}),
383
418
  };
384
419
  return createHash("sha256").update(JSON.stringify(payload)).digest("hex");
385
420
  }
@@ -394,6 +429,7 @@ export async function startLocalTask(
394
429
  session = null,
395
430
  apiOrigin = null,
396
431
  observationFetch = globalThis.fetch,
432
+ deliverableUploadSleep = undefined,
397
433
  } = {},
398
434
  ) {
399
435
  if (!task || task.status !== "queued") throw new Error("Local Task must be queued.");
@@ -475,6 +511,7 @@ export async function startLocalTask(
475
511
 
476
512
  const observationSender = createWorkflowObservationSender({
477
513
  binding: task.workflow_observation_binding,
514
+ startIntent: task.workflow_start_intent,
478
515
  backend: task.backend,
479
516
  session,
480
517
  apiOrigin,
@@ -601,7 +638,22 @@ export async function startLocalTask(
601
638
  0,
602
639
  );
603
640
  if (observationSender) {
604
- task.workflow_observation = await observationSender.flush();
641
+ // flush() is designed not to reject; guard anyway so a sender failure
642
+ // can never leave the Local Task unfinished.
643
+ try {
644
+ task.workflow_observation = await observationSender.flush();
645
+ } catch {
646
+ task.workflow_observation = {
647
+ status: "failed",
648
+ workflow_id:
649
+ task.workflow_observation_binding?.workflow_id ||
650
+ task.workflow_start_intent?.workflow_id ||
651
+ null,
652
+ workflow_run_id: task.workflow_observation_binding?.workflow_run_id || null,
653
+ development_outcome: "unverified",
654
+ reason_code: "observation_sender_error",
655
+ };
656
+ }
605
657
  }
606
658
 
607
659
  let artifacts = [];
@@ -662,6 +714,40 @@ export async function startLocalTask(
662
714
  : [],
663
715
  };
664
716
  task.warnings = task.result.warnings;
717
+ // G3: deliverables are uploaded only after the typed terminal was
718
+ // accepted for a succeeded Provider turn, because Backend authorizes a
719
+ // device claim only against that consumed receipt. The terminal itself
720
+ // was already sent above, so an upload failure never withholds it; the
721
+ // failure is recorded next to the observation instead.
722
+ const typedReceipt =
723
+ task.workflow_observation?.status === "accepted" &&
724
+ typeof task.workflow_observation.receipt_outcome === "string";
725
+ if (
726
+ observationSender &&
727
+ typedReceipt &&
728
+ task.status === "succeeded" &&
729
+ observationSender.terminalOutcome() === "succeeded" &&
730
+ artifacts.length > 0
731
+ ) {
732
+ task.workflow_observation = {
733
+ ...task.workflow_observation,
734
+ deliverable_upload: await uploadWorkflowDeliverables({
735
+ binding: observationSender.binding(),
736
+ observationId: task.workflow_observation.observation_id,
737
+ localTaskId: task.id,
738
+ artifacts,
739
+ readArtifactBytes: async (artifactId) => {
740
+ const verified = await localTaskArtifact(task, artifactId);
741
+ return verified ? readFile(verified.local_path) : null;
742
+ },
743
+ session,
744
+ apiOrigin,
745
+ fetchImpl: observationFetch,
746
+ environment,
747
+ ...(deliverableUploadSleep ? { sleep: deliverableUploadSleep } : {}),
748
+ }),
749
+ };
750
+ }
665
751
  task.completed_at = new Date().toISOString();
666
752
  task.updated_at = task.completed_at;
667
753
  emitTaskEvent(task, "status", {
@@ -771,6 +857,7 @@ export function localTaskSnapshot(task, { includeEvents = true, includeLocalPath
771
857
  idempotency_key_hash: task.idempotency_key_hash || null,
772
858
  request_fingerprint: task.request_fingerprint || null,
773
859
  workflow_observation_binding: task.workflow_observation_binding || null,
860
+ workflow_start_intent: task.workflow_start_intent || null,
774
861
  }
775
862
  : {}),
776
863
  };
@@ -964,6 +1051,10 @@ function restoreLocalTask(snapshot) {
964
1051
  snapshot.workflow_observation_binding,
965
1052
  { provider: snapshot.backend },
966
1053
  );
1054
+ const workflowStartIntent = normalizeWorkflowStartIntent(
1055
+ snapshot.workflow_start_intent,
1056
+ { provider: snapshot.backend },
1057
+ );
967
1058
  return {
968
1059
  ...snapshot,
969
1060
  outputs_root_expected:
@@ -978,6 +1069,7 @@ function restoreLocalTask(snapshot) {
978
1069
  warnings: Array.isArray(snapshot.warnings) ? snapshot.warnings : [],
979
1070
  token_usage: snapshot.token_usage || { records: [] },
980
1071
  workflow_observation_binding: workflowObservationBinding,
1072
+ workflow_start_intent: workflowStartIntent,
981
1073
  workflow_observation: snapshot.workflow_observation || null,
982
1074
  child: null,
983
1075
  operation_promise: null,
@@ -1180,7 +1272,8 @@ function validateIdempotencyKey(value) {
1180
1272
  }
1181
1273
 
1182
1274
  function providerTokenCount(record) {
1183
- return Number(record?.total_tokens || 0);
1275
+ // Budget-weighted (cache reads at 1/10); raw totals stay in token_usage.
1276
+ return providerBudgetTokens(record);
1184
1277
  }
1185
1278
 
1186
1279
  function allowCodexRuntimeTemp(command, runtimeTempRoot) {
@@ -111,9 +111,33 @@ export function canStartProvider(snapshot, { attemptNumber, phase }) {
111
111
  export function tokenUsageTotal(value) {
112
112
  if (!value || typeof value !== "object" || Array.isArray(value)) return 0;
113
113
  if (Array.isArray(value.records)) {
114
- return value.records.reduce((sum, record) => sum + recordTokenTotal(record), 0);
114
+ return value.records.reduce((sum, record) => sum + providerBudgetTokens(record), 0);
115
115
  }
116
- return recordTokenTotal(value);
116
+ return providerBudgetTokens(value);
117
+ }
118
+
119
+ // Cache reads are billed at roughly a tenth of fresh input, and Claude Code
120
+ // re-reads its whole cached context every turn (~300k per short run). Counting
121
+ // them in full made every Claude Local Task exhaust a 100k budget, so the
122
+ // budget counts input + output + cache creation + ceil(cache_read / 10).
123
+ // token_usage records keep the raw totals (including cache reads) unchanged.
124
+ export const CACHE_READ_BUDGET_DIVISOR = 10;
125
+
126
+ export function providerBudgetTokens(value) {
127
+ if (!value || typeof value !== "object" || Array.isArray(value)) return 0;
128
+ const cacheRead = nonNegativeNumber(value.cached_input_tokens);
129
+ if (cacheRead === null) return recordTokenTotal(value);
130
+ const output = nonNegativeNumber(value.tokens_out ?? value.output_tokens) ?? 0;
131
+ const weightedCacheRead = Math.ceil(cacheRead / CACHE_READ_BUDGET_DIVISOR);
132
+ const uncached = nonNegativeNumber(value.uncached_input_tokens);
133
+ if (uncached !== null) {
134
+ // Claude: input_tokens excludes cache reads and cache creation.
135
+ const cacheCreation = nonNegativeNumber(value.cache_creation_input_tokens) ?? 0;
136
+ return uncached + cacheCreation + output + weightedCacheRead;
137
+ }
138
+ // Codex: input_tokens already includes cached_input_tokens.
139
+ const input = nonNegativeNumber(value.tokens_in ?? value.input_tokens) ?? 0;
140
+ return Math.max(input - cacheRead, 0) + output + weightedCacheRead;
117
141
  }
118
142
 
119
143
  function recordTokenTotal(value) {
package/src/runner.js CHANGED
@@ -48,6 +48,7 @@ import { createSqmChecker } from "./sqm/index.js";
48
48
  import {
49
49
  createWorkflowObservationSender,
50
50
  normalizeWorkflowObservationBinding,
51
+ normalizeWorkflowStartIntent,
51
52
  } from "./workflow-observation-sender.js";
52
53
  import { saveCodingRunSnapshot } from "./coding-run-store.js";
53
54
 
@@ -96,7 +97,14 @@ export function createRun({ runId, request, session, apiOrigin, localStateRoot =
96
97
  request?.workflow_observation_binding,
97
98
  { provider: request?.backend },
98
99
  ),
100
+ workflow_start_intent: normalizeWorkflowStartIntent(
101
+ request?.workflow_start_intent,
102
+ { provider: request?.backend },
103
+ ),
99
104
  };
105
+ if (normalizedRequest.workflow_observation_binding && normalizedRequest.workflow_start_intent) {
106
+ throw new Error("WorkFlow binding and start intent are mutually exclusive.");
107
+ }
100
108
  return {
101
109
  id: runId,
102
110
  request: normalizedRequest,
@@ -136,6 +144,17 @@ export function createRun({ runId, request, session, apiOrigin, localStateRoot =
136
144
  },
137
145
  }
138
146
  : {}),
147
+ ...(normalizedRequest.workflow_start_intent
148
+ ? {
149
+ workflow_observation: {
150
+ status: "pending",
151
+ workflow_id: normalizedRequest.workflow_start_intent.workflow_id,
152
+ workflow_run_id: null,
153
+ development_outcome: "unverified",
154
+ start_intent: normalizedRequest.workflow_start_intent,
155
+ },
156
+ }
157
+ : {}),
139
158
  },
140
159
  }),
141
160
  };
@@ -181,6 +200,8 @@ export function restoreRun({ snapshot, session, apiOrigin, localPath = null, loc
181
200
  proposal_id: typeof charter.proposal_id === "string" ? charter.proposal_id : null,
182
201
  workflow_observation_binding:
183
202
  snapshot.metadata?.workflow_observation?.binding ?? null,
203
+ workflow_start_intent:
204
+ snapshot.metadata?.workflow_observation?.start_intent ?? null,
184
205
  charter_context: {
185
206
  source_charter_id: charter.id,
186
207
  audit_backend: charter.audit_backend,
@@ -798,9 +819,11 @@ export async function executeProcessAttempt({
798
819
  const tokenUsageRecords = [];
799
820
  const phase = marker === "SIMY_AUDIT_JSON:" ? "reviewer" : "executor";
800
821
  const observationSender =
801
- phase === "executor" && run.snapshot.metadata?.workflow_observation?.status !== "accepted"
822
+ phase === "executor" &&
823
+ !["accepted", "already_recorded"].includes(run.snapshot.metadata?.workflow_observation?.status)
802
824
  ? createWorkflowObservationSender({
803
825
  binding: run.request.workflow_observation_binding,
826
+ startIntent: run.request.workflow_start_intent,
804
827
  backend,
805
828
  session: run.session,
806
829
  apiOrigin: run.apiOrigin,
@@ -855,16 +878,28 @@ export async function executeProcessAttempt({
855
878
  stdoutDecoder.flush();
856
879
  stderrDecoder.flush();
857
880
  run.child = null;
881
+ // The run must always resolve: a WorkFlow observation failure (or a
882
+ // sender bug) never changes or blocks the Provider result. flush() is
883
+ // bounded by per-request timeouts and is designed not to reject.
858
884
  if (observationSender) {
859
- const observation = await observationSender.flush();
860
- run.snapshot.metadata.workflow_observation = {
861
- ...observation,
862
- binding: run.request.workflow_observation_binding,
863
- };
864
- if (observation.status === "failed") {
885
+ try {
886
+ const observation = await observationSender.flush();
887
+ run.snapshot.metadata.workflow_observation = {
888
+ ...observation,
889
+ binding: run.request.workflow_observation_binding,
890
+ start_intent: run.request.workflow_start_intent,
891
+ };
892
+ if (observation.status === "failed") {
893
+ emitOutput(
894
+ run,
895
+ "WorkFlow observation was not accepted; the Provider result is unchanged and development completion remains unverified.",
896
+ { backend },
897
+ );
898
+ }
899
+ } catch {
865
900
  emitOutput(
866
901
  run,
867
- "WorkFlow observation was not accepted; the Provider result is unchanged and development completion remains unverified.",
902
+ "WorkFlow observation could not be sent; the Provider result is unchanged and development completion remains unverified.",
868
903
  { backend },
869
904
  );
870
905
  }
@@ -0,0 +1,131 @@
1
+ import { constants } from 'node:fs';
2
+ import { lstat, open, readdir, realpath } from 'node:fs/promises';
3
+ import { homedir } from 'node:os';
4
+ import { isAbsolute, join, relative, resolve, sep } from 'node:path';
5
+
6
+ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
7
+ const MAX_PROJECTS = 256;
8
+ const MAX_TRANSCRIPTS = 4096;
9
+ const MAX_PREFIX_BYTES = 256 * 1024;
10
+ const MAX_LINES = 256;
11
+ const MAX_FILE_BYTES = 64 * 1024 * 1024;
12
+
13
+ function within(parent, child) {
14
+ const path = relative(parent, child);
15
+ return path === '' || (path !== '..' && !path.startsWith(`..${sep}`) && !isAbsolute(path));
16
+ }
17
+
18
+ async function directory(path) {
19
+ try {
20
+ const info = await lstat(path);
21
+ return info.isDirectory() && !info.isSymbolicLink();
22
+ } catch { return false; }
23
+ }
24
+
25
+ async function projectsRoot(home) {
26
+ if (typeof home !== 'string' || !isAbsolute(home)) return null;
27
+ const root = join(resolve(home), '.claude', 'projects');
28
+ if (!await directory(home) || !await directory(join(home, '.claude')) || !await directory(root)) return null;
29
+ return realpath(root);
30
+ }
31
+
32
+ function metadataFromPrefix(bytes, id) {
33
+ // Claude's JSONL is a full transcript. Inspect only a bounded prefix for
34
+ // envelope fields; never read message bodies into the returned candidate.
35
+ const text = bytes.toString('utf8');
36
+ const complete = text.slice(0, text.lastIndexOf('\n') + 1);
37
+ if (!complete) return null;
38
+ let cwd = '', title = '', found = false;
39
+ for (const line of complete.split('\n').slice(0, MAX_LINES)) {
40
+ if (!line) continue;
41
+ let item;
42
+ try { item = JSON.parse(line); } catch { return null; }
43
+ if (item.sessionId !== id) return null;
44
+ if (item.type !== 'user' && item.type !== 'assistant') continue;
45
+ if (item.isSidechain === true) return null;
46
+ if (typeof item.cwd !== 'string' || !isAbsolute(item.cwd)) continue;
47
+ if (!cwd) cwd = resolve(item.cwd);
48
+ if (cwd !== resolve(item.cwd)) return null;
49
+ if (!title && typeof item.slug === 'string') title = item.slug.replace(/[\x00-\x1f\x7f]/g, '').slice(0, 120);
50
+ found = true;
51
+ }
52
+ return found ? { cwd, title } : null;
53
+ }
54
+
55
+ async function inspectTranscript(root, projectName, fileName, observedAt) {
56
+ const id = fileName.slice(0, -'.jsonl'.length);
57
+ if (!UUID.test(id)) return null;
58
+ const project = join(root, projectName);
59
+ if (!await directory(project)) return null;
60
+ const path = join(project, fileName);
61
+ let handle;
62
+ try {
63
+ const before = await lstat(path);
64
+ if (!before.isFile() || before.isSymbolicLink() || before.size > MAX_FILE_BYTES) return null;
65
+ if (!within(root, await realpath(path))) return null;
66
+ handle = await open(path, constants.O_RDONLY | (constants.O_NOFOLLOW || 0));
67
+ const opened = await handle.stat();
68
+ if (!opened.isFile() || opened.dev !== before.dev || opened.ino !== before.ino) return null;
69
+ // Recheck the resolved path and inode after opening, so a swapped project
70
+ // directory cannot turn a prior path check into a read outside the index.
71
+ if (!within(root, await realpath(path))) return null;
72
+ const current = await lstat(path);
73
+ if (!current.isFile() || current.isSymbolicLink() || current.dev !== opened.dev || current.ino !== opened.ino) return null;
74
+ const buffer = Buffer.allocUnsafe(Math.min(opened.size, MAX_PREFIX_BYTES));
75
+ const { bytesRead } = await handle.read(buffer, 0, buffer.length, 0);
76
+ const metadata = metadataFromPrefix(buffer.subarray(0, bytesRead), id);
77
+ if (!metadata) return null;
78
+ return {
79
+ provider: 'claude', session: id, cwd: metadata.cwd, title: metadata.title,
80
+ source: 'claude_cli', lastActivityAt: opened.mtimeMs,
81
+ observation: { source: 'claude_local_index', observedAt },
82
+ availability: 'unsupported', model: null,
83
+ capabilities: { canMonitor: true, canEnroll: false, canAutoContinue: false },
84
+ reason: 'Claude session ownership and safe exact-session takeover are unverified; monitoring only.',
85
+ };
86
+ } catch { return null; }
87
+ finally { await handle?.close(); }
88
+ }
89
+
90
+ async function collect({ home = homedir(), limit = 100, session = null } = {}) {
91
+ const root = await projectsRoot(home);
92
+ if (!root) return [];
93
+ const maximum = Number.isInteger(limit) ? Math.max(1, Math.min(limit, 200)) : 100;
94
+ const observedAt = Date.now();
95
+ const result = new Map();
96
+ let inspected = 0;
97
+ let projects;
98
+ try { projects = await readdir(root, { withFileTypes: true }); } catch { return []; }
99
+ for (const entry of projects.filter(item => item.isDirectory() && !item.isSymbolicLink()).slice(0, MAX_PROJECTS)) {
100
+ const project = join(root, entry.name);
101
+ let files;
102
+ try { files = await readdir(project, { withFileTypes: true }); } catch { continue; }
103
+ for (const file of files) {
104
+ if (inspected >= MAX_TRANSCRIPTS) break;
105
+ if (!file.isFile() || file.isSymbolicLink() || !UUID.test(file.name.replace(/\.jsonl$/, '')) || !file.name.endsWith('.jsonl')) continue;
106
+ const id = file.name.slice(0, -'.jsonl'.length);
107
+ if (session && id !== session) continue;
108
+ inspected++;
109
+ const candidate = await inspectTranscript(root, entry.name, file.name, observedAt);
110
+ if (!candidate) continue;
111
+ const key = `${candidate.session}\0${candidate.cwd}`;
112
+ if (!result.has(key) || candidate.lastActivityAt > result.get(key).lastActivityAt) result.set(key, candidate);
113
+ }
114
+ if (inspected >= MAX_TRANSCRIPTS) break;
115
+ }
116
+ const sorted = [...result.values()].sort((a, b) => b.lastActivityAt - a.lastActivityAt || a.session.localeCompare(b.session));
117
+ return session ? sorted : sorted.slice(0, maximum);
118
+ }
119
+
120
+ export async function listClaudeSessions({ home, limit } = {}) {
121
+ return collect({ home, limit });
122
+ }
123
+
124
+ export async function readClaudeSession(session, { home } = {}) {
125
+ const id = typeof session === 'string' ? session : session?.session;
126
+ if (typeof id !== 'string' || !UUID.test(id)) return null;
127
+ const matches = await collect({ home, limit: 200, session: id });
128
+ const selected = typeof session === 'object' && typeof session.cwd === 'string'
129
+ ? matches.filter(item => item.cwd === session.cwd) : matches;
130
+ return selected.length === 1 ? selected[0] : null;
131
+ }