@scotthuang/agent-knock-knock 0.8.1 → 0.10.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.
Files changed (53) hide show
  1. package/CHANGELOG.md +42 -0
  2. package/README.md +45 -17
  3. package/dist/src/agent-session-provider.d.ts +13 -0
  4. package/dist/src/claude-local-transcript-provider.d.ts +29 -1
  5. package/dist/src/claude-local-transcript-provider.js +313 -0
  6. package/dist/src/claude-local-transcript-provider.js.map +1 -1
  7. package/dist/src/claude-terminal-agent-adapter.d.ts +6 -1
  8. package/dist/src/claude-terminal-agent-adapter.js +178 -0
  9. package/dist/src/claude-terminal-agent-adapter.js.map +1 -1
  10. package/dist/src/cli.js +5671 -317
  11. package/dist/src/cli.js.map +1 -1
  12. package/dist/src/codex-local-session-provider.d.ts +3 -1
  13. package/dist/src/codex-local-session-provider.js +17 -0
  14. package/dist/src/codex-local-session-provider.js.map +1 -1
  15. package/dist/src/codex-store-adapter.d.ts +26 -1
  16. package/dist/src/codex-store-adapter.js +575 -2
  17. package/dist/src/codex-store-adapter.js.map +1 -1
  18. package/dist/src/codex-terminal-agent-adapter.d.ts +6 -1
  19. package/dist/src/codex-terminal-agent-adapter.js +192 -0
  20. package/dist/src/codex-terminal-agent-adapter.js.map +1 -1
  21. package/dist/src/managed-session.d.ts +163 -0
  22. package/dist/src/managed-session.js +873 -0
  23. package/dist/src/managed-session.js.map +1 -0
  24. package/dist/src/native-thread-lifecycle-policy.d.ts +57 -0
  25. package/dist/src/native-thread-lifecycle-policy.js +116 -0
  26. package/dist/src/native-thread-lifecycle-policy.js.map +1 -0
  27. package/dist/src/openclaw-plugin-helpers.d.ts +45 -12
  28. package/dist/src/openclaw-plugin-helpers.js +386 -47
  29. package/dist/src/openclaw-plugin-helpers.js.map +1 -1
  30. package/dist/src/openclaw-plugin.js +814 -144
  31. package/dist/src/openclaw-plugin.js.map +1 -1
  32. package/dist/src/protocol.d.ts +28 -1
  33. package/dist/src/protocol.js +93 -4
  34. package/dist/src/protocol.js.map +1 -1
  35. package/dist/src/session-store.d.ts +57 -0
  36. package/dist/src/session-store.js +529 -0
  37. package/dist/src/session-store.js.map +1 -0
  38. package/dist/src/store.d.ts +5 -3
  39. package/dist/src/store.js +422 -17
  40. package/dist/src/store.js.map +1 -1
  41. package/dist/src/terminal-agent-adapter.d.ts +175 -0
  42. package/dist/src/terminal-agent-adapter.js +12 -0
  43. package/dist/src/terminal-agent-adapter.js.map +1 -1
  44. package/dist/src/terminal-agent-bridge.d.ts +9 -0
  45. package/dist/src/terminal-agent-bridge.js +17 -1
  46. package/dist/src/terminal-agent-bridge.js.map +1 -1
  47. package/dist/src/terminal-control-provider.d.ts +2 -0
  48. package/dist/src/terminal-control-provider.js +1 -0
  49. package/dist/src/terminal-control-provider.js.map +1 -1
  50. package/docs/quickstart-tmux.md +23 -2
  51. package/openclaw.plugin.json +19 -3
  52. package/package.json +1 -1
  53. package/templates/openclaw-skills/agent-knock-knock/SKILL.md +42 -17
package/dist/src/store.js CHANGED
@@ -1,14 +1,18 @@
1
- import { randomUUID } from "node:crypto";
1
+ import { createHash, randomUUID } from "node:crypto";
2
2
  import { AsyncLocalStorage } from "node:async_hooks";
3
3
  import fs from "node:fs";
4
4
  import os from "node:os";
5
5
  import path from "node:path";
6
+ import { sessionIdForConversation, turnIdForConversation, validateMessage } from "./protocol.js";
7
+ import { assertManagedSessionState, managedSessionStatesFromConversations, managedSessionStorageKey } from "./managed-session.js";
6
8
  const PRIVATE_DIRECTORY_MODE = 0o700;
7
9
  const PRIVATE_FILE_MODE = 0o600;
8
10
  const STORE_SCHEMA = "agent-knock-knock/store";
9
11
  const STORE_MANIFEST_FILE = "manifest.json";
10
12
  const STORE_CONVERSATIONS_DIRECTORY = "conversations";
11
13
  const STORE_RUNTIME_DIRECTORY = "runtime";
14
+ const STORE_SESSIONS_DIRECTORY = "sessions";
15
+ const STORE_TRANSITIONS_DIRECTORY = "transitions";
12
16
  const STORE_WRITER_LOCK_FILE = ".akk-writer.lock";
13
17
  const STORE_MANIFEST_TEMP_PREFIX = `.${STORE_MANIFEST_FILE}.`;
14
18
  const STORE_MANIFEST_TEMP_SUFFIX = ".tmp";
@@ -21,7 +25,8 @@ const NO_FOLLOW_FLAG = typeof fs.constants.O_NOFOLLOW === "number"
21
25
  ? fs.constants.O_NOFOLLOW
22
26
  : 0;
23
27
  export const STORE_FORMAT_VERSION = 1;
24
- export const STORE_WRITER_PROTOCOL = 1;
28
+ export const STORE_WRITER_PROTOCOL = 3;
29
+ const STORE_UPGRADEABLE_WRITER_PROTOCOLS = new Set([1, 2]);
25
30
  const STORE_WRITER_LEASE_BRAND = Symbol("akk-store-writer-lease");
26
31
  const activeStoreWriterLease = new AsyncLocalStorage();
27
32
  export class StoreCompatibilityError extends Error {
@@ -41,8 +46,46 @@ export function defaultLogDir(workspace = process.cwd()) {
41
46
  }
42
47
  export function ensureDir(dir) {
43
48
  const resolvedDir = path.resolve(dir);
44
- assertNotSymlink(resolvedDir, "store directory");
45
- fs.mkdirSync(resolvedDir, { recursive: true, mode: PRIVATE_DIRECTORY_MODE });
49
+ const missing = [];
50
+ let cursor = resolvedDir;
51
+ while (true) {
52
+ try {
53
+ const existing = fs.lstatSync(cursor);
54
+ if (existing.isSymbolicLink() || !existing.isDirectory()) {
55
+ throw new Error(`store directory must be a real directory, not a symlink: ${cursor}`);
56
+ }
57
+ break;
58
+ }
59
+ catch (error) {
60
+ if (!isNodeError(error, "ENOENT")) {
61
+ throw error;
62
+ }
63
+ missing.unshift(cursor);
64
+ const parent = path.dirname(cursor);
65
+ if (parent === cursor) {
66
+ throw new Error(`could not find an existing parent directory for ${dir}`);
67
+ }
68
+ cursor = parent;
69
+ }
70
+ }
71
+ for (const directory of missing) {
72
+ try {
73
+ fs.mkdirSync(directory, { mode: PRIVATE_DIRECTORY_MODE });
74
+ }
75
+ catch (error) {
76
+ if (!isNodeError(error, "EEXIST")) {
77
+ throw error;
78
+ }
79
+ }
80
+ const created = fs.lstatSync(directory);
81
+ if (created.isSymbolicLink() || !created.isDirectory()) {
82
+ throw new Error(`store directory must be a real directory, not a symlink: ${directory}`);
83
+ }
84
+ fs.chmodSync(directory, PRIVATE_DIRECTORY_MODE);
85
+ // Persist each newly created directory entry before a child entry can be
86
+ // treated as durable. This also covers a recursively new custom Store path.
87
+ fsyncDirectory(path.dirname(directory));
88
+ }
46
89
  const stat = fs.lstatSync(resolvedDir);
47
90
  if (stat.isSymbolicLink() || !stat.isDirectory()) {
48
91
  throw new Error(`store directory must be a real directory, not a symlink: ${dir}`);
@@ -55,6 +98,9 @@ export function storeManifestPath(storeDir = defaultStoreDir()) {
55
98
  export function storeConversationsDir(storeDir = defaultStoreDir()) {
56
99
  return path.join(storeDir, STORE_CONVERSATIONS_DIRECTORY);
57
100
  }
101
+ export function storeSessionsDir(storeDir = defaultStoreDir()) {
102
+ return path.join(storeDir, STORE_SESSIONS_DIRECTORY);
103
+ }
58
104
  export function inspectStoreCompatibility(storeDir = defaultStoreDir()) {
59
105
  const resolvedStoreDir = path.resolve(storeDir);
60
106
  const manifestPath = storeManifestPath(resolvedStoreDir);
@@ -72,7 +118,16 @@ export function inspectStoreCompatibility(storeDir = defaultStoreDir()) {
72
118
  if (!storeStat.isDirectory()) {
73
119
  throw new Error(`store directory must be a real directory: ${storeDir}`);
74
120
  }
75
- if (!fs.existsSync(manifestPath)) {
121
+ let manifestStat;
122
+ try {
123
+ manifestStat = fs.lstatSync(manifestPath);
124
+ }
125
+ catch (error) {
126
+ if (!isNodeError(error, "ENOENT")) {
127
+ throw error;
128
+ }
129
+ }
130
+ if (!manifestStat) {
76
131
  if (storeHasConversationData(resolvedStoreDir)) {
77
132
  return {
78
133
  status: "legacy",
@@ -91,15 +146,26 @@ export function inspectStoreCompatibility(storeDir = defaultStoreDir()) {
91
146
  writable: true
92
147
  };
93
148
  }
149
+ if (manifestStat.isSymbolicLink() || !manifestStat.isFile()) {
150
+ throw new Error(`store manifest must be a regular file: ${manifestPath}`);
151
+ }
94
152
  const manifest = readStoreManifest(manifestPath);
95
153
  const readable = manifest.format_version === STORE_FORMAT_VERSION;
96
- const writable = readable && manifest.writer_protocol === STORE_WRITER_PROTOCOL;
154
+ const compatible = readable && manifest.writer_protocol === STORE_WRITER_PROTOCOL;
155
+ const upgradeable = readable &&
156
+ STORE_UPGRADEABLE_WRITER_PROTOCOLS.has(manifest.writer_protocol);
157
+ const writable = compatible || upgradeable;
97
158
  return {
98
- status: readable && writable ? "compatible" : "incompatible",
159
+ status: compatible
160
+ ? "compatible"
161
+ : upgradeable
162
+ ? "upgradeable"
163
+ : "incompatible",
99
164
  store_dir: storeDir,
100
165
  manifest_path: manifestPath,
101
166
  readable,
102
167
  writable,
168
+ upgradeable,
103
169
  format_version: manifest.format_version,
104
170
  writer_protocol: manifest.writer_protocol,
105
171
  ...(!readable
@@ -242,13 +308,17 @@ export function logPathForStatePath(statePath) {
242
308
  return statePath.replace(/\.state\.json$/, ".ndjson");
243
309
  }
244
310
  export function saveState(statePath, conversation) {
311
+ // Validate the state identity before acquiring a writer lease. In particular,
312
+ // a malformed state must not initialize or upgrade the Store manifest.
313
+ assertConversationStateIdentity(conversation);
245
314
  const paths = assertCanonicalConversationDataPath(statePath, "state.json");
315
+ assertConversationStateIdentity(conversation, path.basename(paths.conversationDir));
316
+ assertConversationStorageMetadata(statePath, conversation);
246
317
  withStoreWriterLease(paths.storeDir, () => {
247
318
  saveStateWithWriterLease(statePath, conversation);
248
319
  });
249
320
  }
250
321
  function saveStateWithWriterLease(statePath, conversation) {
251
- validateConversationId(conversation.conversation_id);
252
322
  secureConversationStorageMetadata(statePath, conversation);
253
323
  prepareDataDirectory(statePath);
254
324
  const serialized = `${JSON.stringify(conversation, null, 2)}\n`;
@@ -289,7 +359,7 @@ export function loadState(statePath) {
289
359
  assertNotSymlink(path.dirname(statePath), "conversation directory");
290
360
  const fd = openRegularFileNoFollow(statePath, fs.constants.O_RDONLY, "state file");
291
361
  try {
292
- return JSON.parse(fs.readFileSync(fd, "utf8"));
362
+ return withConversationIdentity(JSON.parse(fs.readFileSync(fd, "utf8")));
293
363
  }
294
364
  finally {
295
365
  fs.closeSync(fd);
@@ -336,6 +406,31 @@ function withCanonicalConversationStorage(conversation, paths) {
336
406
  event_log_path: path.resolve(paths.logPath)
337
407
  };
338
408
  }
409
+ function withConversationIdentity(conversation) {
410
+ const { sessionId, turnId } = assertConversationStateIdentity(conversation);
411
+ return {
412
+ ...conversation,
413
+ session_id: sessionId,
414
+ turn_id: turnId
415
+ };
416
+ }
417
+ function assertConversationStateIdentity(value, expectedConversationId) {
418
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
419
+ throw new Error("conversation state must be an object");
420
+ }
421
+ const conversation = value;
422
+ validateConversationId(conversation.conversation_id);
423
+ const sessionId = sessionIdForConversation(conversation);
424
+ const turnId = turnIdForConversation(conversation);
425
+ if (turnId !== conversation.conversation_id) {
426
+ throw new Error(`conversation turn_id ${turnId} does not match its store identity ${conversation.conversation_id}`);
427
+ }
428
+ if (expectedConversationId !== undefined &&
429
+ conversation.conversation_id !== expectedConversationId) {
430
+ throw new Error(`conversation id ${conversation.conversation_id} does not match its store directory ${expectedConversationId}`);
431
+ }
432
+ return { conversation, sessionId, turnId };
433
+ }
339
434
  export function appendEvent(logPath, event) {
340
435
  const paths = assertCanonicalConversationDataPath(logPath, "events.ndjson");
341
436
  withStoreWriterLease(paths.storeDir, () => {
@@ -444,6 +539,12 @@ function prepareDataDirectory(dataPath) {
444
539
  }
445
540
  }
446
541
  function secureConversationStorageMetadata(statePath, conversation) {
542
+ const paths = assertConversationStorageMetadata(statePath, conversation);
543
+ ensureStoreWritable(paths.storeDir);
544
+ ensureStoreDir(paths.storeDir, paths.conversationDir);
545
+ ensureDir(paths.conversationDir);
546
+ }
547
+ function assertConversationStorageMetadata(statePath, conversation) {
447
548
  if (typeof conversation.store_dir !== "string" ||
448
549
  typeof conversation.conversation_dir !== "string" ||
449
550
  typeof conversation.state_path !== "string") {
@@ -455,9 +556,7 @@ function secureConversationStorageMetadata(statePath, conversation) {
455
556
  path.resolve(paths.statePath) !== path.resolve(statePath)) {
456
557
  throw new Error(`conversation storage metadata does not match state path: ${statePath}`);
457
558
  }
458
- ensureStoreWritable(paths.storeDir);
459
- ensureStoreDir(paths.storeDir, paths.conversationDir);
460
- ensureDir(paths.conversationDir);
559
+ return paths;
461
560
  }
462
561
  function secureEventStorageMetadata(logPath, event) {
463
562
  if (typeof event.conversation_id !== "string") {
@@ -495,6 +594,132 @@ function assertCanonicalConversationDataPath(dataPath, expectedBasename) {
495
594
  }
496
595
  return canonical;
497
596
  }
597
+ function managedSessionStatePath(storeDir, sessionId) {
598
+ return path.join(storeSessionsDir(storeDir), managedSessionStorageKey(sessionId), "state.json");
599
+ }
600
+ function tryReadMaterializedManagedSession(statePath, expectedSessionId) {
601
+ try {
602
+ const fd = openRegularFileNoFollow(statePath, fs.constants.O_RDONLY, "managed session state");
603
+ try {
604
+ const parsed = JSON.parse(fs.readFileSync(fd, "utf8"));
605
+ assertManagedSessionState(parsed, expectedSessionId);
606
+ return parsed;
607
+ }
608
+ finally {
609
+ fs.closeSync(fd);
610
+ }
611
+ }
612
+ catch (error) {
613
+ if (isNodeError(error, "ENOENT")) {
614
+ return undefined;
615
+ }
616
+ throw error;
617
+ }
618
+ }
619
+ function materializeManagedSessionStatesWhileLocked(storeDir, states, replaceMigrationRevisionOne) {
620
+ const sessionsDir = storeSessionsDir(storeDir);
621
+ assertNotSymlink(sessionsDir, "managed sessions directory");
622
+ ensureDir(sessionsDir);
623
+ const writes = [];
624
+ for (const state of states) {
625
+ assertManagedSessionState(state, state.session_id);
626
+ const statePath = managedSessionStatePath(storeDir, state.session_id);
627
+ const existing = tryReadMaterializedManagedSession(statePath, state.session_id);
628
+ if (existing && JSON.stringify(existing) === JSON.stringify(state)) {
629
+ continue;
630
+ }
631
+ if (existing &&
632
+ !(replaceMigrationRevisionOne &&
633
+ existing.revision === 1 &&
634
+ existing.lineage.created_by === "migration")) {
635
+ throw new Error(`managed Session ${state.session_id} already has non-migration state; ` +
636
+ "refusing to overwrite it during Store materialization");
637
+ }
638
+ writes.push({ path: statePath, state });
639
+ }
640
+ for (const write of writes) {
641
+ atomicSaveManagedSessionState(write.path, write.state);
642
+ }
643
+ // Record-directory creation and every state rename are durable before a
644
+ // protocol-3 manifest can become visible.
645
+ fsyncDirectory(sessionsDir);
646
+ fsyncDirectory(storeDir);
647
+ }
648
+ function assertUpgradeableManagedSessionTree(storeDir, expectedStates) {
649
+ const sessionsDir = storeSessionsDir(storeDir);
650
+ let rootStat;
651
+ try {
652
+ rootStat = fs.lstatSync(sessionsDir);
653
+ }
654
+ catch (error) {
655
+ if (isNodeError(error, "ENOENT")) {
656
+ return;
657
+ }
658
+ throw error;
659
+ }
660
+ if (rootStat.isSymbolicLink() || !rootStat.isDirectory()) {
661
+ throw new Error(`managed sessions directory must be a real directory: ${sessionsDir}`);
662
+ }
663
+ const expectedByKey = new Map(expectedStates.map((state) => [managedSessionStorageKey(state.session_id), state]));
664
+ for (const entry of fs.readdirSync(sessionsDir, { withFileTypes: true })) {
665
+ const entryPath = path.join(sessionsDir, entry.name);
666
+ if (entry.isSymbolicLink() ||
667
+ !entry.isDirectory() ||
668
+ !/^[0-9a-f]{64}$/u.test(entry.name)) {
669
+ throw new Error(`predecessor Store contains an invalid managed Session entry: ${entryPath}`);
670
+ }
671
+ const expected = expectedByKey.get(entry.name);
672
+ if (!expected) {
673
+ throw new Error(`predecessor Store contains unexpected managed Session state: ${entryPath}`);
674
+ }
675
+ const statePath = path.join(entryPath, "state.json");
676
+ // An expected empty record directory is a recoverable crash residue. Any
677
+ // present state must already be a strict, correctly keyed Session record.
678
+ tryReadMaterializedManagedSession(statePath, expected.session_id);
679
+ }
680
+ }
681
+ function atomicSaveManagedSessionState(statePath, state) {
682
+ const recordDir = path.dirname(statePath);
683
+ const sessionsDir = path.dirname(recordDir);
684
+ assertNotSymlink(sessionsDir, "managed sessions directory");
685
+ ensureDir(recordDir);
686
+ assertNotSymlink(recordDir, "managed session directory");
687
+ const recordStat = fs.lstatSync(recordDir);
688
+ if (!recordStat.isDirectory()) {
689
+ throw new Error(`managed session directory must be a real directory: ${recordDir}`);
690
+ }
691
+ assertWritableDataPath(statePath, "managed session state");
692
+ const tempPath = path.join(recordDir, `.state.json.${process.pid}.${randomUUID()}.tmp`);
693
+ let fd;
694
+ try {
695
+ fd = fs.openSync(tempPath, fs.constants.O_CREAT |
696
+ fs.constants.O_EXCL |
697
+ fs.constants.O_WRONLY |
698
+ NO_FOLLOW_FLAG, PRIVATE_FILE_MODE);
699
+ fs.fchmodSync(fd, PRIVATE_FILE_MODE);
700
+ fs.writeFileSync(fd, `${JSON.stringify(state, null, 2)}\n`, "utf8");
701
+ fs.fsyncSync(fd);
702
+ fs.closeSync(fd);
703
+ fd = undefined;
704
+ assertWritableDataPath(statePath, "managed session state");
705
+ fs.renameSync(tempPath, statePath);
706
+ fs.chmodSync(statePath, PRIVATE_FILE_MODE);
707
+ fsyncDirectory(recordDir);
708
+ }
709
+ finally {
710
+ if (fd !== undefined) {
711
+ fs.closeSync(fd);
712
+ }
713
+ try {
714
+ fs.unlinkSync(tempPath);
715
+ }
716
+ catch (error) {
717
+ if (!isNodeError(error, "ENOENT")) {
718
+ throw error;
719
+ }
720
+ }
721
+ }
722
+ }
498
723
  function ensureStoreDir(storeDir, currentConversationDir) {
499
724
  const resolvedStoreDir = path.resolve(storeDir);
500
725
  assertNotSymlink(resolvedStoreDir, "store directory");
@@ -519,6 +744,11 @@ function ensureStoreDir(storeDir, currentConversationDir) {
519
744
  if (entry.name === STORE_RUNTIME_DIRECTORY && entry.isDirectory()) {
520
745
  return true;
521
746
  }
747
+ if ((entry.name === STORE_SESSIONS_DIRECTORY ||
748
+ entry.name === STORE_TRANSITIONS_DIRECTORY) &&
749
+ entry.isDirectory()) {
750
+ return true;
751
+ }
522
752
  if (entryPath !== resolvedConversationsDir || !entry.isDirectory()) {
523
753
  return false;
524
754
  }
@@ -541,10 +771,7 @@ function ensureStoreDir(storeDir, currentConversationDir) {
541
771
  function prepareStoreRootForWriterLock(storeDir) {
542
772
  const resolvedStoreDir = path.resolve(storeDir);
543
773
  if (!fs.existsSync(resolvedStoreDir)) {
544
- fs.mkdirSync(resolvedStoreDir, {
545
- recursive: true,
546
- mode: PRIVATE_DIRECTORY_MODE
547
- });
774
+ ensureDir(resolvedStoreDir);
548
775
  }
549
776
  assertNotSymlink(resolvedStoreDir, "store directory");
550
777
  const stat = fs.lstatSync(resolvedStoreDir);
@@ -561,11 +788,16 @@ function ensureStoreWritableWhileLocked(storeDir) {
561
788
  createStoreManifest(storeDir);
562
789
  compatibility = inspectStoreCompatibility(storeDir);
563
790
  }
791
+ else if (compatibility.status === "upgradeable") {
792
+ upgradeStoreWriterProtocolWhileLocked(storeDir);
793
+ compatibility = inspectStoreCompatibility(storeDir);
794
+ }
564
795
  assertWritableCompatibility(compatibility);
565
796
  // Permission repair is a write and therefore happens only after the
566
797
  // manifest has been validated under the root writer lock.
567
798
  fs.chmodSync(storeDir, PRIVATE_DIRECTORY_MODE);
568
799
  ensureDir(storeConversationsDir(storeDir));
800
+ ensureDir(storeSessionsDir(storeDir));
569
801
  return readStoreManifest(storeManifestPath(storeDir));
570
802
  }
571
803
  function assertWritableCompatibility(compatibility) {
@@ -656,9 +888,175 @@ function createStoreManifest(storeDir) {
656
888
  throw error;
657
889
  }
658
890
  }
891
+ /** Upgrade the one explicitly supported predecessor while holding the root lock. */
892
+ function upgradeStoreWriterProtocolWhileLocked(storeDir) {
893
+ const manifestPath = storeManifestPath(storeDir);
894
+ const previous = readStoreManifestSnapshot(manifestPath);
895
+ assertUpgradeableStoreManifest(previous.manifest, storeDir);
896
+ // Protocol 3 makes Session state the routing/binding authority. Validate all
897
+ // predecessor Turns, derive every Session without using mutable Turn
898
+ // recency, and durably materialize those records before publishing the new
899
+ // manifest. Turn files and event logs remain byte-for-byte untouched.
900
+ const stateSnapshots = readUpgradeableStoreStateSnapshots(storeDir);
901
+ const sessions = managedSessionStatesFromConversations(stateSnapshots.map(({ conversation }) => conversation));
902
+ assertUpgradeableManagedSessionTree(storeDir, sessions);
903
+ materializeManagedSessionStatesWhileLocked(storeDir, sessions, true);
904
+ const upgraded = {
905
+ ...previous.manifest,
906
+ writer_protocol: STORE_WRITER_PROTOCOL,
907
+ created_at: previous.manifest.created_at
908
+ };
909
+ const tempPath = path.join(storeDir, `${STORE_MANIFEST_TEMP_PREFIX}${process.pid}.${randomUUID()}${STORE_MANIFEST_TEMP_SUFFIX}`);
910
+ let fd;
911
+ try {
912
+ fd = fs.openSync(tempPath, fs.constants.O_CREAT |
913
+ fs.constants.O_EXCL |
914
+ fs.constants.O_WRONLY |
915
+ NO_FOLLOW_FLAG, PRIVATE_FILE_MODE);
916
+ fs.fchmodSync(fd, PRIVATE_FILE_MODE);
917
+ fs.writeFileSync(fd, `${JSON.stringify(upgraded, null, 2)}\n`, "utf8");
918
+ fs.fsyncSync(fd);
919
+ fs.closeSync(fd);
920
+ fd = undefined;
921
+ // A process that ignores the root writer lock must not trick us into
922
+ // replacing a different manifest. Re-check both file identity and the
923
+ // supported predecessor immediately before the atomic rename.
924
+ revalidateUpgradeableStoreStateSnapshots(storeDir, stateSnapshots);
925
+ const current = readStoreManifestSnapshot(manifestPath);
926
+ if (current.device !== previous.device ||
927
+ current.inode !== previous.inode ||
928
+ current.manifest.schema !== previous.manifest.schema ||
929
+ current.manifest.format_version !== previous.manifest.format_version ||
930
+ current.manifest.writer_protocol !== previous.manifest.writer_protocol ||
931
+ current.manifest.created_at !== previous.manifest.created_at) {
932
+ throw new Error("AKK store manifest changed while upgrading writer protocol; refusing to replace it");
933
+ }
934
+ const manifestPathStat = fs.lstatSync(manifestPath, { bigint: true });
935
+ if (manifestPathStat.isSymbolicLink() ||
936
+ !manifestPathStat.isFile() ||
937
+ manifestPathStat.dev !== current.device ||
938
+ manifestPathStat.ino !== current.inode) {
939
+ throw new Error("AKK store manifest path changed while upgrading writer protocol; refusing to replace it");
940
+ }
941
+ fs.renameSync(tempPath, manifestPath);
942
+ fsyncDirectory(storeDir);
943
+ }
944
+ finally {
945
+ if (fd !== undefined) {
946
+ fs.closeSync(fd);
947
+ }
948
+ try {
949
+ fs.unlinkSync(tempPath);
950
+ }
951
+ catch (error) {
952
+ if (!isNodeError(error, "ENOENT")) {
953
+ throw error;
954
+ }
955
+ }
956
+ }
957
+ }
958
+ function assertUpgradeableStoreManifest(manifest, storeDir) {
959
+ if (manifest.schema !== STORE_SCHEMA ||
960
+ manifest.format_version !== STORE_FORMAT_VERSION ||
961
+ !STORE_UPGRADEABLE_WRITER_PROTOCOLS.has(manifest.writer_protocol)) {
962
+ throw new StoreCompatibilityError(`store is not one of the supported writer protocol predecessors ${[
963
+ ...STORE_UPGRADEABLE_WRITER_PROTOCOLS
964
+ ].join(", ")}`, inspectStoreCompatibility(storeDir));
965
+ }
966
+ }
967
+ function readUpgradeableStoreStateSnapshots(storeDir) {
968
+ const conversationsDir = storeConversationsDir(storeDir);
969
+ let conversationsStat;
970
+ try {
971
+ conversationsStat = fs.lstatSync(conversationsDir);
972
+ }
973
+ catch (error) {
974
+ if (isNodeError(error, "ENOENT")) {
975
+ return [];
976
+ }
977
+ throw error;
978
+ }
979
+ if (conversationsStat.isSymbolicLink() || !conversationsStat.isDirectory()) {
980
+ throw new Error(`conversations directory must be a real directory: ${conversationsDir}`);
981
+ }
982
+ const snapshots = [];
983
+ for (const entry of fs.readdirSync(conversationsDir, { withFileTypes: true })) {
984
+ const conversationDir = path.join(conversationsDir, entry.name);
985
+ const conversationStat = fs.lstatSync(conversationDir);
986
+ if (conversationStat.isSymbolicLink()) {
987
+ throw new Error(`conversation directory must not be a symlink: ${conversationDir}`);
988
+ }
989
+ if (!conversationStat.isDirectory()) {
990
+ continue;
991
+ }
992
+ const statePath = path.join(conversationDir, "state.json");
993
+ let stateStat;
994
+ try {
995
+ stateStat = fs.lstatSync(statePath);
996
+ }
997
+ catch (error) {
998
+ if (isNodeError(error, "ENOENT")) {
999
+ continue;
1000
+ }
1001
+ throw error;
1002
+ }
1003
+ if (stateStat.isSymbolicLink() || !stateStat.isFile()) {
1004
+ throw new Error(`state file must be a regular file: ${statePath}`);
1005
+ }
1006
+ const stateFd = openRegularFileNoFollow(statePath, fs.constants.O_RDONLY, "state file");
1007
+ try {
1008
+ let parsed;
1009
+ let contents;
1010
+ try {
1011
+ contents = fs.readFileSync(stateFd, "utf8");
1012
+ parsed = JSON.parse(contents);
1013
+ }
1014
+ catch (error) {
1015
+ throw new Error(`invalid conversation state during Store upgrade: ${statePath}: ${error instanceof Error ? error.message : String(error)}`);
1016
+ }
1017
+ const identity = assertConversationStateIdentity(parsed, entry.name);
1018
+ const stat = fs.fstatSync(stateFd, { bigint: true });
1019
+ snapshots.push({
1020
+ conversation: {
1021
+ ...identity.conversation,
1022
+ session_id: identity.sessionId,
1023
+ turn_id: identity.turnId
1024
+ },
1025
+ statePath,
1026
+ device: stat.dev,
1027
+ inode: stat.ino,
1028
+ contentsSha256: createHash("sha256").update(contents).digest("hex")
1029
+ });
1030
+ }
1031
+ finally {
1032
+ fs.closeSync(stateFd);
1033
+ }
1034
+ }
1035
+ return snapshots;
1036
+ }
1037
+ function revalidateUpgradeableStoreStateSnapshots(storeDir, snapshots) {
1038
+ const current = readUpgradeableStoreStateSnapshots(storeDir);
1039
+ const expectedByPath = new Map(snapshots.map((snapshot) => [snapshot.statePath, snapshot]));
1040
+ if (current.length !== snapshots.length) {
1041
+ throw new Error("conversation set changed during Store upgrade");
1042
+ }
1043
+ for (const actual of current) {
1044
+ const expected = expectedByPath.get(actual.statePath);
1045
+ if (!expected ||
1046
+ actual.device !== expected.device ||
1047
+ actual.inode !== expected.inode ||
1048
+ actual.contentsSha256 !== expected.contentsSha256) {
1049
+ throw new Error(`conversation state changed during Store upgrade: ${actual.statePath}`);
1050
+ }
1051
+ }
1052
+ }
659
1053
  function readStoreManifest(manifestPath) {
1054
+ return readStoreManifestSnapshot(manifestPath).manifest;
1055
+ }
1056
+ function readStoreManifestSnapshot(manifestPath) {
660
1057
  const fd = openRegularFileNoFollow(manifestPath, fs.constants.O_RDONLY, "store manifest");
661
1058
  try {
1059
+ const stat = fs.fstatSync(fd, { bigint: true });
662
1060
  const parsed = JSON.parse(fs.readFileSync(fd, "utf8"));
663
1061
  if (parsed.schema !== STORE_SCHEMA ||
664
1062
  !Number.isSafeInteger(parsed.format_version) ||
@@ -666,7 +1064,11 @@ function readStoreManifest(manifestPath) {
666
1064
  typeof parsed.created_at !== "string") {
667
1065
  throw new Error(`invalid AKK store manifest: ${manifestPath}`);
668
1066
  }
669
- return parsed;
1067
+ return {
1068
+ manifest: parsed,
1069
+ device: stat.dev,
1070
+ inode: stat.ino
1071
+ };
670
1072
  }
671
1073
  finally {
672
1074
  fs.closeSync(fd);
@@ -885,9 +1287,12 @@ function isNodeError(error, code) {
885
1287
  return error instanceof Error && error.code === code;
886
1288
  }
887
1289
  export function messageEvent(message) {
1290
+ validateMessage(message);
888
1291
  return {
889
1292
  ts: message.ts,
890
1293
  conversation_id: message.conversation_id,
1294
+ session_id: message.session_id ?? message.conversation_id,
1295
+ turn_id: message.turn_id ?? message.conversation_id,
891
1296
  event: "message",
892
1297
  from: message.from,
893
1298
  to: message.to,