tinker-agent 2.4.0 → 2.6.0

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.
@@ -0,0 +1,304 @@
1
+ import type {
2
+ ContextRevisionId,
3
+ IterationId,
4
+ MessageId,
5
+ ProtocolFrameId,
6
+ RuntimeIdFactory,
7
+ SessionId,
8
+ ToolCallId,
9
+ TurnId,
10
+ } from "../ids/runtime-id";
11
+ import type { MeasuredContextAnchor } from "../agent/context-meter";
12
+ import type { ModelContextProfile } from "../model/model-context-profile";
13
+ import type { ModelMessageProtocol } from "../model/model-client";
14
+ import { sha256, stableJsonStringify } from "../model/model-request-preflight";
15
+ import type { ProjectInstructionManifest } from "../instructions/project-instructions";
16
+ import type { SkillScope } from "../skills/skill-loader";
17
+ import type {
18
+ ContextSurfaceChanges,
19
+ StoredContextSurfaceV8,
20
+ } from "../context/context-surface";
21
+ import type { SwapOverride } from "../context/context-revision";
22
+ import { SWAP_OBSERVATION_FORMAT } from "../context/context-swap-renderer";
23
+
24
+ export type SessionMediaCompatibility = {
25
+ readonly policyVersion: string;
26
+ readonly policySha256: string;
27
+ readonly inputModalities: readonly ("text" | "image")[];
28
+ readonly toolResultModalities: readonly ("text" | "image")[];
29
+ };
30
+
31
+ export type CompletedTurnMessageSnapshot =
32
+ | {
33
+ readonly ordinal: number;
34
+ readonly role: "user";
35
+ readonly content: string;
36
+ }
37
+ | {
38
+ readonly ordinal: number;
39
+ readonly role: "assistant";
40
+ readonly content: string | null;
41
+ readonly reasoningContent?: string | null;
42
+ }
43
+ | {
44
+ readonly ordinal: number;
45
+ readonly role: "tool";
46
+ readonly name: string;
47
+ readonly content: string;
48
+ };
49
+
50
+ export type CompletedTurnSnapshot = {
51
+ readonly messages: readonly CompletedTurnMessageSnapshot[];
52
+ };
53
+
54
+ export type SessionCompatibilityContract = {
55
+ modelName: string;
56
+ profileName?: string;
57
+ includeReasoningContent: boolean;
58
+ contextProfile: ModelContextProfile;
59
+ messageProtocol: ModelMessageProtocol;
60
+ media: SessionMediaCompatibility;
61
+ };
62
+
63
+ export type StoredSessionMetaV10 = {
64
+ schemaVersion: 10;
65
+ schemaFingerprint: string;
66
+ initializationState: "creating" | "ready";
67
+ sessionId: SessionId;
68
+ workspaceRoot: string;
69
+ modelName: string;
70
+ systemPromptSha256: string;
71
+ projectInstruction?: ProjectInstructionManifest;
72
+ sessionCompatibilityJson: string | null;
73
+ sessionCompatibilitySha256: string | null;
74
+ activeRevisionId: ContextRevisionId | null;
75
+ nextTurnNumber: number;
76
+ nextEventSequence: number;
77
+ openCount: number;
78
+ createdAt: string;
79
+ updatedAt: string;
80
+ lastOpenedAt: string;
81
+ lastClosedAt: string | null;
82
+ lastCloseReason:
83
+ | "oneshot_complete"
84
+ | "tui_exit"
85
+ | "session_switch"
86
+ | "runner_failed"
87
+ | "initialization_failed"
88
+ | null;
89
+ };
90
+
91
+ export type SessionCloseReason = NonNullable<StoredSessionMetaV10["lastCloseReason"]>;
92
+
93
+ export type SessionRecoveryResult = {
94
+ recoveredTurnId?: TurnId;
95
+ recoveredFrameId?: ProtocolFrameId;
96
+ syntheticCompletionCount: number;
97
+ recallIndexRebuilt: boolean;
98
+ };
99
+
100
+ export type StoredMeasuredContextState = {
101
+ revisionId: ContextRevisionId;
102
+ anchor: MeasuredContextAnchor;
103
+ };
104
+
105
+ export type CommitSwapRevisionInput = {
106
+ revisionId: ContextRevisionId;
107
+ expectedBaseRevisionId: ContextRevisionId;
108
+ expectedBaseRevisionNumber: number;
109
+ expectedCanonicalThroughOrdinal: number;
110
+ expectedBaseActiveOverrideManifestSha256: string;
111
+ policyVersion: "swap-only-v1";
112
+ rendererFormat: typeof SWAP_OBSERVATION_FORMAT;
113
+ planHash: string;
114
+ addedOverrides: readonly SwapOverride[];
115
+ nextActiveOverrideManifestSha256: string;
116
+ canonicalSequenceSha256: string;
117
+ renderedMessageSha256: string;
118
+ activeTurnId?: TurnId;
119
+ };
120
+
121
+ export type CommitSwapRevisionFaultStage =
122
+ | "before_revision_insert"
123
+ | "after_revision_insert"
124
+ | "after_first_override_insert"
125
+ | "after_overrides_insert"
126
+ | "after_measurement_delete"
127
+ | "after_active_update";
128
+
129
+ export type CommitSwapRevisionOptions = {
130
+ faultInjector?: (stage: CommitSwapRevisionFaultStage) => void;
131
+ };
132
+
133
+ export type CommitSurfaceRefreshInput = {
134
+ revisionId: ContextRevisionId;
135
+ expectedBaseRevisionId: ContextRevisionId;
136
+ expectedBaseRevisionNumber: number;
137
+ expectedCanonicalThroughOrdinal: number;
138
+ expectedBaseActiveOverrideManifestSha256: string;
139
+ surface: StoredContextSurfaceV8;
140
+ changes: ContextSurfaceChanges;
141
+ changeManifestSha256: string;
142
+ canonicalSequenceSha256: string;
143
+ renderedMessageSha256: string;
144
+ };
145
+
146
+ export type CommitSurfaceRefreshFaultStage =
147
+ | "before_surface_insert"
148
+ | "after_surface_insert"
149
+ | "after_revision_insert"
150
+ | "after_measurement_delete"
151
+ | "after_active_update";
152
+
153
+ export type CommitSurfaceRefreshOptions = {
154
+ faultInjector?: (stage: CommitSurfaceRefreshFaultStage) => void;
155
+ };
156
+
157
+ export type CommitPrefixRetirementRevisionInput = {
158
+ revisionId: ContextRevisionId;
159
+ expectedBaseRevisionId: ContextRevisionId;
160
+ expectedBaseRevisionNumber: number;
161
+ expectedBaseKeepFromOrdinal: number;
162
+ expectedCanonicalThroughOrdinal: number;
163
+ expectedSurfaceSha256: string;
164
+ expectedBaseActiveOverrideManifestSha256: string;
165
+ policyVersion: "recall-first-retirement-v1";
166
+ planHash: string;
167
+ nextKeepFromOrdinal: number;
168
+ retiredThroughOrdinal: number;
169
+ retiredTurnCount: number;
170
+ retiredFrameCount: number;
171
+ retiredMessageCount: number;
172
+ nextActiveOverrideCount: number;
173
+ nextActiveOverrideManifestSha256: string;
174
+ canonicalSequenceSha256: string;
175
+ renderedMessageSha256: string;
176
+ activeTurnId?: TurnId;
177
+ };
178
+
179
+ export type CommitPrefixRetirementRevisionFaultStage =
180
+ | "before_revision_insert"
181
+ | "after_revision_insert"
182
+ | "after_override_readback"
183
+ | "after_measurement_delete"
184
+ | "after_active_update"
185
+ | "after_snapshot_readback";
186
+
187
+ export type CommitPrefixRetirementRevisionOptions = {
188
+ faultInjector?: (stage: CommitPrefixRetirementRevisionFaultStage) => void;
189
+ };
190
+
191
+ export type StoredSkillActivation = {
192
+ readonly activationMessageId: MessageId;
193
+ readonly toolCallId: ToolCallId;
194
+ readonly sessionId: SessionId;
195
+ readonly name: string;
196
+ readonly scope: SkillScope;
197
+ readonly skillFileSha256: string;
198
+ readonly state: "pending" | "dispatched" | "promoted" | "rejected";
199
+ readonly dispatchedIterationId?: IterationId;
200
+ readonly settledRevisionId?: ContextRevisionId;
201
+ readonly rejectionReason?: string;
202
+ readonly createdAt: string;
203
+ readonly updatedAt: string;
204
+ };
205
+
206
+ export type CommitSkillsUpdateInput = {
207
+ revisionId: ContextRevisionId;
208
+ expectedBaseRevisionId: ContextRevisionId;
209
+ expectedBaseRevisionNumber: number;
210
+ expectedCanonicalThroughOrdinal: number;
211
+ expectedBaseActiveOverrideManifestSha256: string;
212
+ surface: StoredContextSurfaceV8;
213
+ changes: ContextSurfaceChanges;
214
+ changeManifestSha256: string;
215
+ activationManifestSha256: string;
216
+ addedOverrides: readonly SwapOverride[];
217
+ nextActiveOverrideManifestSha256: string;
218
+ settlements: readonly {
219
+ activationMessageId: MessageId;
220
+ name: string;
221
+ state: "promoted" | "rejected";
222
+ rejectionReason?: string;
223
+ }[];
224
+ canonicalSequenceSha256: string;
225
+ renderedMessageSha256: string;
226
+ };
227
+
228
+ export type CommitSkillsUpdateFaultStage =
229
+ | "before_surface_insert"
230
+ | "after_surface_insert"
231
+ | "after_revision_insert"
232
+ | "after_first_override_insert"
233
+ | "after_overrides_insert"
234
+ | "after_activations_update"
235
+ | "after_measurement_delete"
236
+ | "after_active_update";
237
+
238
+ export type CommitSkillsUpdateOptions = {
239
+ faultInjector?: (stage: CommitSkillsUpdateFaultStage) => void;
240
+ };
241
+
242
+ export type CloneSessionFaultStage =
243
+ | "after_staging_mkdir"
244
+ | "after_snapshot"
245
+ | "after_trigger_drop"
246
+ | "after_identity_update"
247
+ | "after_revision_hash_rewrite"
248
+ | "after_trigger_reinstall"
249
+ | "after_recall_validation"
250
+ | "after_event_rewrite"
251
+ | "after_observation_render"
252
+ | "after_artifact_validation"
253
+ | "before_publish_rename";
254
+
255
+ export type CloneSessionStoreInput = {
256
+ targetSessionId: SessionId;
257
+ faultInjector?: (stage: CloneSessionFaultStage) => void;
258
+ };
259
+
260
+ export function skillActivationManifestSha256(
261
+ settlements: CommitSkillsUpdateInput["settlements"],
262
+ ): string {
263
+ return sha256(
264
+ stableJsonStringify(
265
+ [...settlements]
266
+ .sort(
267
+ (left, right) =>
268
+ compareCanonicalText(left.name, right.name) ||
269
+ compareCanonicalText(left.activationMessageId, right.activationMessageId),
270
+ )
271
+ .map((settlement) => ({
272
+ activationMessageId: settlement.activationMessageId,
273
+ name: settlement.name,
274
+ state: settlement.state,
275
+ ...(settlement.rejectionReason === undefined
276
+ ? {}
277
+ : { rejectionReason: settlement.rejectionReason }),
278
+ })),
279
+ ),
280
+ );
281
+ }
282
+
283
+ export type CreateNewSessionStoreInput = {
284
+ workspaceRoot: string;
285
+ sessionId: SessionId;
286
+ modelName: string;
287
+ systemPrompt: string;
288
+ projectInstruction?: ProjectInstructionManifest;
289
+ idFactory: RuntimeIdFactory;
290
+ clock?: () => string;
291
+ homeRoot?: string;
292
+ };
293
+
294
+ export type OpenSessionStoreInput = {
295
+ workspaceRoot: string;
296
+ sessionId: SessionId;
297
+ clock?: () => string;
298
+ allowIncomplete?: boolean;
299
+ homeRoot?: string;
300
+ };
301
+
302
+ function compareCanonicalText(left: string, right: string): number {
303
+ return left < right ? -1 : left > right ? 1 : 0;
304
+ }
@@ -0,0 +1,245 @@
1
+ import path from "node:path";
2
+ import { chmod, lstat, mkdir, realpath, rmdir, unlink } from "node:fs/promises";
3
+ import type { SessionId } from "../ids/runtime-id";
4
+ import { canonicalHomeRoot, workspaceStorageRoot } from "./workspace-storage";
5
+ import { SessionError } from "./session-errors";
6
+
7
+ export async function assertPathMissing(
8
+ filePath: string,
9
+ sessionId: SessionId,
10
+ ): Promise<void> {
11
+ try {
12
+ await lstat(filePath);
13
+ } catch (error) {
14
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") {
15
+ return;
16
+ }
17
+ throw error;
18
+ }
19
+ throw new SessionError(
20
+ "SESSION_ALREADY_EXISTS",
21
+ "clone_session",
22
+ `Session directory already exists: ${filePath}.`,
23
+ { sessionId },
24
+ );
25
+ }
26
+
27
+ export async function canonicalWorkspaceRoot(workspaceRoot: string): Promise<string> {
28
+ if (!path.isAbsolute(workspaceRoot)) {
29
+ throw new Error("Session workspace root must be absolute.");
30
+ }
31
+ return realpath(workspaceRoot);
32
+ }
33
+
34
+ export async function ensureSessionsRoot(
35
+ workspaceRoot: string,
36
+ homeRoot?: string,
37
+ ): Promise<string> {
38
+ const tinkerRoot = workspaceStorageRoot(
39
+ workspaceRoot,
40
+ await canonicalHomeRoot(homeRoot),
41
+ );
42
+ const sessionsRoot = path.join(tinkerRoot, "sessions");
43
+ await mkdir(sessionsRoot, { recursive: true, mode: 0o700 });
44
+ await validateSessionsRoot(sessionsRoot);
45
+ await chmod(tinkerRoot, 0o700);
46
+ await chmod(sessionsRoot, 0o700);
47
+ return sessionsRoot;
48
+ }
49
+
50
+ export async function validateSessionsRoot(
51
+ sessionsRoot: string,
52
+ sessionId?: SessionId,
53
+ ): Promise<void> {
54
+ const tinkerRoot = path.dirname(sessionsRoot);
55
+ for (const directory of [tinkerRoot, sessionsRoot]) {
56
+ let stats;
57
+ try {
58
+ stats = await lstat(directory);
59
+ } catch (error) {
60
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") {
61
+ throw new SessionError(
62
+ "SESSION_STORE_NOT_FOUND",
63
+ "validate_session_root",
64
+ `Session root does not exist: ${directory}.`,
65
+ { sessionId, cause: error },
66
+ );
67
+ }
68
+ throw error;
69
+ }
70
+ if (!stats.isDirectory() || stats.isSymbolicLink()) {
71
+ throw new SessionError(
72
+ "SESSION_PERMISSION_INVALID",
73
+ "validate_session_root",
74
+ `Session root must not be a symlink: ${directory}.`,
75
+ { sessionId },
76
+ );
77
+ }
78
+ }
79
+ if ((await realpath(sessionsRoot)) !== sessionsRoot) {
80
+ throw new SessionError(
81
+ "SESSION_PERMISSION_INVALID",
82
+ "validate_session_root",
83
+ `Session root resolves outside its canonical path: ${sessionsRoot}.`,
84
+ { sessionId },
85
+ );
86
+ }
87
+ }
88
+
89
+ export function safeSessionDirectory(
90
+ sessionsRoot: string,
91
+ sessionId: SessionId,
92
+ ): string {
93
+ const value = String(sessionId);
94
+ if (
95
+ value.trim() === "" ||
96
+ value !== value.trim() ||
97
+ value.includes("/") ||
98
+ value.includes("\\") ||
99
+ value === "." ||
100
+ value === ".."
101
+ ) {
102
+ throw new SessionError(
103
+ "SESSION_ID_INVALID",
104
+ "resolve_session_path",
105
+ `Unsafe session ID: ${JSON.stringify(value)}.`,
106
+ { sessionId },
107
+ );
108
+ }
109
+ const directory = path.join(sessionsRoot, value);
110
+ if (path.dirname(directory) !== sessionsRoot) {
111
+ throw new SessionError(
112
+ "SESSION_ID_INVALID",
113
+ "resolve_session_path",
114
+ `Session ID escapes the sessions directory: ${JSON.stringify(value)}.`,
115
+ { sessionId },
116
+ );
117
+ }
118
+ return directory;
119
+ }
120
+
121
+ export async function validateSecureDirectory(
122
+ directory: string,
123
+ sessionId: SessionId,
124
+ ): Promise<void> {
125
+ let stats;
126
+ try {
127
+ stats = await lstat(directory);
128
+ } catch (error) {
129
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") {
130
+ throw new SessionError(
131
+ "SESSION_STORE_NOT_FOUND",
132
+ "open_session",
133
+ `Session directory does not exist: ${directory}.`,
134
+ { sessionId, cause: error },
135
+ );
136
+ }
137
+ throw error;
138
+ }
139
+ if (
140
+ !stats.isDirectory() ||
141
+ stats.isSymbolicLink() ||
142
+ (stats.mode & 0o077) !== 0 ||
143
+ (typeof process.getuid === "function" && stats.uid !== process.getuid())
144
+ ) {
145
+ throw new SessionError(
146
+ "SESSION_PERMISSION_INVALID",
147
+ "open_session",
148
+ `Session directory must be an owner-only real directory: ${directory}.`,
149
+ { sessionId },
150
+ );
151
+ }
152
+ }
153
+
154
+ export async function validateSecureFile(
155
+ filePath: string,
156
+ sessionId: SessionId,
157
+ ): Promise<void> {
158
+ let stats;
159
+ try {
160
+ stats = await lstat(filePath);
161
+ } catch (error) {
162
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") {
163
+ throw new SessionError(
164
+ "SESSION_STORE_NOT_FOUND",
165
+ "open_session",
166
+ `Session database does not exist: ${filePath}.`,
167
+ { sessionId, cause: error },
168
+ );
169
+ }
170
+ throw error;
171
+ }
172
+ if (
173
+ !stats.isFile() ||
174
+ stats.isSymbolicLink() ||
175
+ (stats.mode & 0o077) !== 0 ||
176
+ (typeof process.getuid === "function" && stats.uid !== process.getuid())
177
+ ) {
178
+ throw new SessionError(
179
+ "SESSION_PERMISSION_INVALID",
180
+ "open_session",
181
+ `Session database must be an owner-only regular file: ${filePath}.`,
182
+ { sessionId },
183
+ );
184
+ }
185
+ }
186
+
187
+ export async function validateSecureOptionalFile(
188
+ filePath: string,
189
+ sessionId: SessionId,
190
+ ): Promise<void> {
191
+ try {
192
+ await lstat(filePath);
193
+ } catch (error) {
194
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") {
195
+ return;
196
+ }
197
+ throw error;
198
+ }
199
+ await validateSecureFile(filePath, sessionId);
200
+ }
201
+
202
+ export async function removeKnownInitializationFiles(
203
+ sessionDirectory: string,
204
+ ): Promise<void> {
205
+ for (const name of [
206
+ "session.sqlite-wal",
207
+ "session.sqlite-shm",
208
+ "session.sqlite",
209
+ "events.jsonl",
210
+ "observations.md",
211
+ "active.lock.reclaim",
212
+ "active.lock",
213
+ ]) {
214
+ await unlinkIfExists(path.join(sessionDirectory, name));
215
+ }
216
+ try {
217
+ await rmdir(sessionDirectory);
218
+ } catch (error) {
219
+ if (
220
+ !new Set(["ENOENT", "ENOTEMPTY"]).has((error as NodeJS.ErrnoException).code ?? "")
221
+ ) {
222
+ throw error;
223
+ }
224
+ }
225
+ }
226
+
227
+ export async function chmodIfExists(filePath: string, mode: number): Promise<void> {
228
+ try {
229
+ await chmod(filePath, mode);
230
+ } catch (error) {
231
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") {
232
+ throw error;
233
+ }
234
+ }
235
+ }
236
+
237
+ export async function unlinkIfExists(filePath: string): Promise<void> {
238
+ try {
239
+ await unlink(filePath);
240
+ } catch (error) {
241
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") {
242
+ throw error;
243
+ }
244
+ }
245
+ }