claude-simple 0.2.25 → 0.2.27

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/collab.js CHANGED
@@ -15,6 +15,12 @@ export function roleAtLeast(have, need) {
15
15
  export function createCollab({ filePath }) {
16
16
  let data = { users: [], locks: {}, routes: {} };
17
17
  let loaded = false;
18
+ let chain = Promise.resolve();
19
+ function withMutex(fn) {
20
+ const run = chain.then(fn, fn);
21
+ chain = run.then(() => undefined, () => undefined);
22
+ return run;
23
+ }
18
24
 
19
25
  async function load() {
20
26
  if (loaded) return data;
@@ -49,82 +55,95 @@ export function createCollab({ filePath }) {
49
55
  function token() { return crypto.randomBytes(16).toString('hex'); }
50
56
 
51
57
  async function acquire({ key, kind, holder, holderRole, lockToken, ttlMs, exclusive = true }) {
52
- await load();
53
- purgeLocks();
54
- if (!roleAtLeast(holderRole, 'editor')) {
55
- return { code: 403, error: 'viewers cannot take exclusive locks' };
56
- }
57
- if (!key) return { code: 400, error: 'key required' };
58
- ttlMs = Math.min(Math.max(ttlMs || DEFAULT_TTL_MS, 5_000), 5 * 60_000);
59
- const cur = data.locks[key];
60
- if (!cur) {
61
- const l = {
62
- key, kind, holder, holderRole,
63
- token: lockToken || token(),
64
- exclusive, acquiredAt: now(), expiresAt: now() + ttlMs, version: 1,
65
- };
66
- data.locks[key] = l;
67
- await save();
68
- return { code: 200, lock: { ...l } };
69
- }
70
- if (cur.holder === holder && (!lockToken || lockToken === cur.token)) {
71
- cur.expiresAt = now() + ttlMs;
72
- if (!lockToken) {
73
- cur.token = token();
74
- cur.version += 1;
58
+ return withMutex(async () => {
59
+ await load();
60
+ purgeLocks();
61
+ if (!roleAtLeast(holderRole, 'editor')) {
62
+ return { code: 403, error: 'viewers cannot take exclusive locks' };
75
63
  }
76
- await save();
77
- return { code: 200, lock: { ...cur } };
78
- }
79
- const pub = { ...cur, token: undefined };
80
- return { code: 423, error: 'locked', lock: pub };
64
+ if (!key) return { code: 400, error: 'key required' };
65
+ ttlMs = Math.min(Math.max(ttlMs || DEFAULT_TTL_MS, 5_000), 5 * 60_000);
66
+ const cur = data.locks[key];
67
+ if (!cur) {
68
+ const l = {
69
+ key, kind, holder, holderRole,
70
+ token: lockToken || token(),
71
+ exclusive, acquiredAt: now(), expiresAt: now() + ttlMs, version: 1,
72
+ };
73
+ data.locks[key] = l;
74
+ await save();
75
+ return { code: 200, lock: { ...l } };
76
+ }
77
+ // Same principal always reclaims. Never rotate the token on a token-less
78
+ // refresh: rotating locked the holder's other tabs out of their own session.
79
+ if (cur.holder === holder) {
80
+ cur.expiresAt = now() + ttlMs;
81
+ cur.holderRole = holderRole || cur.holderRole;
82
+ await save();
83
+ return { code: 200, lock: { ...cur } };
84
+ }
85
+ const pub = { ...cur, token: undefined };
86
+ return { code: 423, error: 'locked', lock: pub };
87
+ });
81
88
  }
82
89
 
83
90
  async function heartbeat({ key, holder, lockToken, ttlMs }) {
84
- await load();
85
- purgeLocks();
86
- const cur = data.locks[key];
87
- if (!cur) return { code: 404, error: 'no lock' };
88
- if (cur.holder !== holder || cur.token !== lockToken) {
89
- return { code: 409, error: 'token mismatch' };
90
- }
91
- cur.expiresAt = now() + (ttlMs || DEFAULT_TTL_MS);
92
- await save();
93
- return { code: 200, lock: { ...cur } };
91
+ return withMutex(async () => {
92
+ await load();
93
+ purgeLocks();
94
+ const cur = data.locks[key];
95
+ if (!cur) return { code: 404, error: 'no lock' };
96
+ if (cur.holder !== holder || cur.token !== lockToken) {
97
+ return { code: 409, error: 'token mismatch' };
98
+ }
99
+ cur.expiresAt = now() + (ttlMs || DEFAULT_TTL_MS);
100
+ await save();
101
+ return { code: 200, lock: { ...cur } };
102
+ });
94
103
  }
95
104
 
96
105
  async function release({ key, holder, lockToken, force }) {
97
- await load();
98
- const cur = data.locks[key];
99
- if (!cur) return { code: 200 };
100
- if (!force && (cur.holder !== holder || cur.token !== lockToken)) {
101
- return { code: 409, error: 'token mismatch' };
102
- }
103
- delete data.locks[key];
104
- await save();
105
- return { code: 200 };
106
+ return withMutex(async () => {
107
+ await load();
108
+ const cur = data.locks[key];
109
+ if (!cur) return { code: 200 };
110
+ // Holder can drop their own lease without presenting the token (Flutter
111
+ // lock list has no stored token). Anyone else needs force-break.
112
+ if (!force && cur.holder !== holder) {
113
+ return { code: 409, error: 'token mismatch' };
114
+ }
115
+ delete data.locks[key];
116
+ await save();
117
+ return { code: 200 };
118
+ });
106
119
  }
107
120
 
108
121
  async function requireLock({ key, lockToken, writer, writerRole }) {
109
- await load();
110
- purgeLocks();
111
- let cur = data.locks[key];
112
- if (!cur) {
113
- const l = {
114
- key, kind: 'auto', holder: writer, holderRole: writerRole,
115
- token: lockToken || token(), exclusive: true,
116
- acquiredAt: now(), expiresAt: now() + AUTO_CLAIM_MS, version: 1,
117
- };
118
- data.locks[key] = l;
119
- await save();
120
- return { code: 200, lock: { ...l } };
121
- }
122
- if (cur.holder !== writer || (lockToken && lockToken !== cur.token)) {
122
+ return withMutex(async () => {
123
+ await load();
124
+ purgeLocks();
125
+ if (!roleAtLeast(writerRole, 'editor')) {
126
+ return { code: 403, error: 'forbidden' };
127
+ }
128
+ let cur = data.locks[key];
129
+ if (!cur) {
130
+ const l = {
131
+ key, kind: 'auto', holder: writer, holderRole: writerRole,
132
+ token: lockToken || token(), exclusive: true,
133
+ acquiredAt: now(), expiresAt: now() + AUTO_CLAIM_MS, version: 1,
134
+ };
135
+ data.locks[key] = l;
136
+ await save();
137
+ return { code: 200, lock: { ...l } };
138
+ }
139
+ // Same holder may omit or present a stale token; they already own the lease.
140
+ if (cur.holder === writer) {
141
+ cur.expiresAt = now() + DEFAULT_TTL_MS;
142
+ await save();
143
+ return { code: 200, lock: { ...cur } };
144
+ }
123
145
  return { code: 423, error: 'locked', lock: { ...cur, token: undefined } };
124
- }
125
- cur.expiresAt = now() + DEFAULT_TTL_MS;
126
- await save();
127
- return { code: 200, lock: { ...cur } };
146
+ });
128
147
  }
129
148
 
130
149
  async function listLocks() {
@@ -27,6 +27,9 @@ export function normalizeCommand(msg) {
27
27
  requestId: msg.requestId || null,
28
28
  allow: msg.allow,
29
29
  always: msg.always,
30
+ // An immediate replacement turn keeps any server-side follow-ups. This
31
+ // is opt-in; the legacy Stop action continues to discard them.
32
+ preserveQueue: msg.preserveQueue === true,
30
33
  cwd: msg.cwd || msg.options?.cwd || null,
31
34
  };
32
35
  }
@@ -42,6 +45,7 @@ export function normalizeCommand(msg) {
42
45
  requestId: null,
43
46
  allow: undefined,
44
47
  always: false,
48
+ preserveQueue: false,
45
49
  cwd: msg.options?.cwd || null,
46
50
  };
47
51
  }
@@ -57,6 +61,7 @@ export function normalizeCommand(msg) {
57
61
  requestId: null,
58
62
  allow: undefined,
59
63
  always: false,
64
+ preserveQueue: false,
60
65
  cwd: null,
61
66
  };
62
67
  }
@@ -72,6 +77,7 @@ export function normalizeCommand(msg) {
72
77
  requestId: msg.requestId || msg.request_id,
73
78
  allow: msg.allow !== false && msg.allow !== 0 && msg.allow !== '0',
74
79
  always: !!msg.always,
80
+ preserveQueue: false,
75
81
  cwd: null,
76
82
  };
77
83
  }
@@ -87,6 +93,7 @@ export function normalizeCommand(msg) {
87
93
  requestId: null,
88
94
  allow: undefined,
89
95
  always: false,
96
+ preserveQueue: false,
90
97
  cwd: msg.cwd || null,
91
98
  };
92
99
  }
@@ -102,6 +109,7 @@ export function normalizeCommand(msg) {
102
109
  requestId: null,
103
110
  allow: undefined,
104
111
  always: false,
112
+ preserveQueue: false,
105
113
  cwd: null,
106
114
  };
107
115
  }