@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/README.md +41 -0
- package/package.json +4 -3
- package/src/agent.js +144 -2
- package/src/backend-executable.js +2 -1
- package/src/cli-contract.js +1 -1
- package/src/codex-plugins/simy/hooks/delivery-loop.mjs +654 -35
- package/src/codex-plugins/simy/skills/delivery-loop/SKILL.md +20 -2
- package/src/codex-work-pattern-sync.js +940 -0
- package/src/delivery-hooks/command.js +28 -7
- package/src/delivery-hooks/desktop.js +38 -4
- package/src/delivery-hooks/supervisor.js +281 -0
- package/src/desktop-host.js +62 -7
- package/src/index.js +7 -0
- package/src/local-task.js +96 -3
- package/src/orchestrator/budget.js +26 -2
- package/src/runner.js +43 -8
- package/src/session-supervision/claude-sessions.js +131 -0
- package/src/session-supervision/codex-app-server.js +290 -0
- package/src/session-supervision/index.js +140 -0
- package/src/session-supervision/original-session-target.js +20 -0
- package/src/windows-update.js +28 -2
- package/src/workflow-deliverable-upload.js +454 -0
- package/src/workflow-observation-sender.js +348 -86
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 +
|
|
114
|
+
return value.records.reduce((sum, record) => sum + providerBudgetTokens(record), 0);
|
|
115
115
|
}
|
|
116
|
-
return
|
|
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" &&
|
|
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
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
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
|
|
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
|
+
}
|