@awak-app/simy-cli 0.5.43 → 0.5.45

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,369 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import {
3
+ listRecords,
4
+ updateRecord,
5
+ } from '../codex-plugins/simy/hooks/delivery-loop.mjs';
6
+ import { associationRevision } from './action-association.js';
7
+ import { ActionAssociationApiError } from './action-association-api.js';
8
+
9
+ export const sessionAssociationContext = (candidate) => {
10
+ const grounded =
11
+ candidate.analysis?.state === 'continue' &&
12
+ candidate.analysis?.basis?.some((item) => item.role === 'user');
13
+ const objective =
14
+ ((grounded && candidate.analysis.objective) || candidate.title || '').slice(
15
+ 0,
16
+ 1000,
17
+ ) || 'Selected conversation';
18
+ const context = {
19
+ objective,
20
+ deliverables: grounded
21
+ ? (candidate.analysis.remaining || []).slice(0, 8)
22
+ : [],
23
+ projectHints: [],
24
+ repositoryUrls: [],
25
+ relatedUrls: [],
26
+ actionIds: [],
27
+ sourceRefs: [],
28
+ basis: [],
29
+ };
30
+ return {
31
+ ...context,
32
+ revision: associationRevision({
33
+ ...context,
34
+ provider: candidate.provider,
35
+ session: candidate.session,
36
+ cwd: candidate.cwd,
37
+ }),
38
+ };
39
+ };
40
+ const namespaceFor = (origin, scope) =>
41
+ JSON.stringify([new URL(origin).origin, scope.organizationId, scope.userId]);
42
+
43
+ // This store grants no continuation authority. Only validated discovery metadata
44
+ // can create identities; record locks serialize identity creation and corrections.
45
+ export function createOrdinaryAssociationStore({
46
+ root,
47
+ api,
48
+ webOrigin,
49
+ now = Date.now,
50
+ onChange = () => {},
51
+ }) {
52
+ const inFlight = new Map();
53
+ const views = new Map(),
54
+ abort = new AbortController();
55
+ let serial = Promise.resolve(),
56
+ closed = false;
57
+ const find = async (candidate) =>
58
+ (await listRecords(root)).find(
59
+ (item) =>
60
+ item.provider === candidate.provider &&
61
+ item.session === candidate.session,
62
+ );
63
+ async function ensure(candidate, known) {
64
+ if (
65
+ !['codex', 'claude'].includes(candidate.provider) ||
66
+ !candidate.session ||
67
+ !candidate.cwd
68
+ )
69
+ throw new ActionAssociationApiError('invalid_request');
70
+ const existing = known === undefined ? await find(candidate) : known;
71
+ if (existing?.associationTaskId && existing.cwd === candidate.cwd)
72
+ return existing;
73
+ return updateRecord(
74
+ root,
75
+ candidate.provider,
76
+ candidate.session,
77
+ (previous) => {
78
+ if (previous && previous.cwd !== candidate.cwd)
79
+ throw new ActionAssociationApiError('conflict');
80
+ const record = previous || {
81
+ schemaVersion: 1,
82
+ provider: candidate.provider,
83
+ session: candidate.session,
84
+ cwd: candidate.cwd,
85
+ run: null,
86
+ };
87
+ record.associationTaskId ||=
88
+ record.run?.managed?.taskId || randomUUID();
89
+ return record;
90
+ },
91
+ );
92
+ }
93
+ async function sync(candidate, force = false, known) {
94
+ const task = candidate.taskId || known?.associationTaskId;
95
+ if (inFlight.has(task)) await inFlight.get(task);
96
+ if (closed) return;
97
+ const record = known || (await ensure(candidate)),
98
+ taskId = record.run?.managed?.taskId || record.associationTaskId;
99
+ candidate.taskId = taskId;
100
+ candidate.binding = { ...candidate.binding, taskId };
101
+ if (record.run?.managed) return;
102
+ const auth = await api.authRevision(),
103
+ context = sessionAssociationContext(candidate);
104
+ let view = views.get(taskId);
105
+ if (!auth) {
106
+ views.set(taskId, { auth, status: 'signed_out' });
107
+ return;
108
+ }
109
+ if (
110
+ !force &&
111
+ view?.auth === auth &&
112
+ view.contextRevision === context.revision &&
113
+ now() < view.nextRefresh
114
+ )
115
+ return;
116
+ view = {
117
+ auth,
118
+ contextRevision: context.revision,
119
+ status: 'loading',
120
+ nextRefresh: now() + 15000,
121
+ };
122
+ views.set(taskId, view);
123
+ try {
124
+ const snapshot = await api.candidates(
125
+ { taskId, context, limit: 10 },
126
+ { signal: abort.signal },
127
+ );
128
+ if (closed || (await api.authRevision()) !== auth)
129
+ throw new ActionAssociationApiError('account_changed');
130
+ if (
131
+ snapshot.taskId !== taskId ||
132
+ snapshot.contextRevision !== context.revision ||
133
+ (snapshot.binding && snapshot.binding.taskId !== taskId)
134
+ )
135
+ throw new ActionAssociationApiError('invalid_response');
136
+ const namespace = namespaceFor(webOrigin, snapshot.scope);
137
+ const persisted = await updateRecord(
138
+ root,
139
+ candidate.provider,
140
+ candidate.session,
141
+ (latest) => {
142
+ if (latest.associationTaskId !== taskId || latest.run?.managed)
143
+ return latest;
144
+ if (
145
+ latest.actionAssociation?.namespace === namespace &&
146
+ (latest.actionAssociation.binding?.revision || 0) >
147
+ (snapshot.binding?.revision || 0)
148
+ )
149
+ return latest;
150
+ latest.actionAssociation = {
151
+ namespace,
152
+ authRevision: auth,
153
+ binding: snapshot.binding || null,
154
+ contextRevision: context.revision,
155
+ status: snapshot.binding?.action
156
+ ? 'bound'
157
+ : snapshot.binding
158
+ ? 'unlinked'
159
+ : 'no_match',
160
+ };
161
+ return latest;
162
+ },
163
+ );
164
+ if (
165
+ persisted?.actionAssociation?.namespace === namespace &&
166
+ (persisted.actionAssociation.binding?.revision || 0) >
167
+ (snapshot.binding?.revision || 0)
168
+ )
169
+ snapshot.binding = persisted.actionAssociation.binding;
170
+ Object.assign(view, {
171
+ namespace,
172
+ snapshot,
173
+ status: snapshot.binding?.action
174
+ ? 'bound'
175
+ : snapshot.binding
176
+ ? 'unlinked'
177
+ : 'no_match',
178
+ });
179
+ } catch (error) {
180
+ view.status = error.code || 'offline';
181
+ view.snapshot = null;
182
+ }
183
+ }
184
+ return {
185
+ async decorate(sessions) {
186
+ const records = new Map(
187
+ (await listRecords(root)).map((record) => [
188
+ `${record.provider}:${record.session}`,
189
+ record,
190
+ ]),
191
+ );
192
+ const auth = await api.authRevision();
193
+ for (const candidate of sessions) {
194
+ const record = await ensure(
195
+ candidate,
196
+ records.get(`${candidate.provider}:${candidate.session}`) || null,
197
+ ),
198
+ taskId = record.run?.managed?.taskId || record.associationTaskId;
199
+ candidate.taskId = taskId;
200
+ candidate.binding = { ...candidate.binding, taskId };
201
+ if (record.run?.managed) continue;
202
+ const cached = views.get(taskId),
203
+ context = sessionAssociationContext(candidate);
204
+ if (
205
+ auth &&
206
+ !closed &&
207
+ inFlight.size < 3 &&
208
+ !inFlight.has(taskId) &&
209
+ (!cached ||
210
+ cached.auth !== auth ||
211
+ cached.contextRevision !== context.revision ||
212
+ now() >= cached.nextRefresh)
213
+ ) {
214
+ const work = sync(candidate, false, record)
215
+ .catch(() => {})
216
+ .finally(() => {
217
+ inFlight.delete(taskId);
218
+ if (!closed) onChange();
219
+ });
220
+ inFlight.set(taskId, work);
221
+ }
222
+ const view = views.get(taskId);
223
+ const snapshot = view?.auth === auth && auth ? view.snapshot : null;
224
+ const binding = snapshot?.binding;
225
+ candidate.actionAssociation = {
226
+ status: !auth ? 'signed_out' : view?.status || 'loading',
227
+ binding: binding
228
+ ? {
229
+ ...binding,
230
+ evidence: [],
231
+ reason: (binding.reason || '').slice(0, 60),
232
+ coveredWork: (binding.coveredWork || '').slice(0, 60),
233
+ action: binding.action
234
+ ? {
235
+ ...binding.action,
236
+ title: (binding.action.title || '').slice(0, 120),
237
+ description: null,
238
+ }
239
+ : null,
240
+ }
241
+ : null,
242
+ candidates: (snapshot?.candidates || []).slice(0, 5).map((item) => ({
243
+ id: item.id,
244
+ revision: item.revision,
245
+ title: item.title.slice(0, 120),
246
+ })),
247
+ canCorrect: Boolean(snapshot),
248
+ contextRevision: view?.contextRevision || null,
249
+ candidateRevision: snapshot?.candidateRevision || null,
250
+ truncated: (snapshot?.candidates?.length || 0) > 5,
251
+ };
252
+ }
253
+ return sessions;
254
+ },
255
+ correct(command, candidate) {
256
+ const work = serial.then(async () => {
257
+ const before = await find(candidate);
258
+ if (
259
+ !before ||
260
+ before.associationTaskId !== command.taskId ||
261
+ before.run?.managed
262
+ )
263
+ throw new ActionAssociationApiError('invalid_request');
264
+ const auth = await api.authRevision();
265
+ if (!auth) throw new ActionAssociationApiError('signed_out');
266
+ await sync(candidate, true);
267
+ const view = views.get(command.taskId);
268
+ if (
269
+ !view?.snapshot ||
270
+ view.auth !== auth ||
271
+ (await api.authRevision()) !== auth
272
+ )
273
+ throw new ActionAssociationApiError(
274
+ view?.status || 'account_changed',
275
+ );
276
+ if (command.action === 'session_action_refresh') return;
277
+ if (
278
+ (command.expectedContextRevision || command.contextRevision) &&
279
+ (command.expectedContextRevision || command.contextRevision) !==
280
+ view.contextRevision
281
+ )
282
+ throw new ActionAssociationApiError('conflict');
283
+ const snapshot = view.snapshot,
284
+ selected =
285
+ command.action === 'session_action_select'
286
+ ? snapshot.candidates.find((item) => item.id === command.actionId)
287
+ : null;
288
+ if (command.action !== 'session_action_unlink' && !selected)
289
+ throw new ActionAssociationApiError('invalid_request');
290
+ if (
291
+ (command.expectedCandidateRevision &&
292
+ command.expectedCandidateRevision !== snapshot.candidateRevision) ||
293
+ (selected &&
294
+ command.expectedActionRevision &&
295
+ command.expectedActionRevision !== selected.revision)
296
+ )
297
+ throw new ActionAssociationApiError('conflict');
298
+ const binding = snapshot.binding;
299
+ // Retransmitting the same click is successful without a second write.
300
+ if (
301
+ binding?.origin === 'user' &&
302
+ (binding.action?.id || null) === (selected?.id || null)
303
+ )
304
+ return;
305
+ if ((binding?.revision ?? null) !== command.expectedBindingRevision)
306
+ throw new ActionAssociationApiError('conflict');
307
+ try {
308
+ const saved = await api.saveBinding(
309
+ command.taskId,
310
+ {
311
+ expectedRevision: command.expectedBindingRevision,
312
+ contextRevision: view.contextRevision,
313
+ candidateRevision: snapshot.candidateRevision,
314
+ origin: 'user',
315
+ actionId: selected?.id || null,
316
+ actionRevision: selected?.revision || null,
317
+ relation: selected ? 'part_of' : null,
318
+ coveredWork: sessionAssociationContext(candidate).objective,
319
+ reason: selected
320
+ ? 'Selected by the user.'
321
+ : 'Unlinked by the user.',
322
+ evidence: [],
323
+ },
324
+ { signal: abort.signal },
325
+ );
326
+ if (
327
+ (await api.authRevision()) !== auth ||
328
+ namespaceFor(webOrigin, saved.scope) !== view.namespace ||
329
+ saved.binding?.taskId !== command.taskId
330
+ )
331
+ throw new ActionAssociationApiError('account_changed');
332
+ await updateRecord(
333
+ root,
334
+ candidate.provider,
335
+ candidate.session,
336
+ (latest) => {
337
+ if (
338
+ latest.associationTaskId !== command.taskId ||
339
+ latest.run?.managed
340
+ )
341
+ throw new ActionAssociationApiError('conflict');
342
+ latest.actionAssociation = {
343
+ namespace: view.namespace,
344
+ authRevision: auth,
345
+ binding: saved.binding,
346
+ contextRevision: view.contextRevision,
347
+ status: saved.binding.action ? 'bound' : 'unlinked',
348
+ };
349
+ return latest;
350
+ },
351
+ );
352
+ view.nextRefresh = 0;
353
+ } catch (error) {
354
+ view.nextRefresh = 0;
355
+ if (error.code === 'conflict') await sync(candidate, true);
356
+ throw error;
357
+ }
358
+ });
359
+ serial = work.catch(() => {});
360
+ return work;
361
+ },
362
+ async close() {
363
+ closed = true;
364
+ abort.abort();
365
+ await Promise.allSettled([serial, ...inFlight.values()]);
366
+ views.clear();
367
+ },
368
+ };
369
+ }
@@ -91,7 +91,8 @@ export async function discoverSessions({ root, openClient = openCodexAppServer,
91
91
  autoContinueEpoch: record?.autoContinue === true ? record.autoContinueEpoch : null,
92
92
  ...autoContinueState(record, isRunning, supported || liveHook),
93
93
  lastActivityAt: item.lastActivityAt, observation: item.observation, availability: item.availability,
94
- binding: { mode, taskId: run?.managed?.taskId || null },
94
+ taskId: run?.managed?.taskId || record?.associationTaskId || null,
95
+ binding: { mode, taskId: run?.managed?.taskId || record?.associationTaskId || null },
95
96
  originalSessionTarget: originalSessionTarget(item.provider, item.session),
96
97
  continuationMode: item.provider === 'claude' ? liveHook ? 'live_hook' : 'manual' : supported ? 'codex_app_server' : 'manual',
97
98
  capabilities: { canMonitor: item.capabilities?.canMonitor ?? true,
@@ -24,6 +24,7 @@ export async function claudePluginFiles() {
24
24
  files.set("hooks/action-association.mjs", await readFile(path.join(SOURCE, "hooks/action-association.mjs"), "utf8"));
25
25
  files.set("hooks/workspace-lease.mjs", await readFile(path.join(SOURCE, "hooks/workspace-lease.mjs"), "utf8"));
26
26
  files.set("hooks/connection-reminder.mjs", await readFile(path.join(SOURCE, "hooks/connection-reminder.mjs"), "utf8"));
27
+ files.set("hooks/dev-work.mjs", await readFile(path.join(SOURCE, "hooks/dev-work.mjs"), "utf8"));
27
28
  const hooks = JSON.parse(await readFile(path.join(SOURCE, "hooks/hooks.json"), "utf8"));
28
29
  delete hooks.hooks.Interrupt; // Codex-only event; Claude interruptions cannot be vetoed.
29
30
  hooks.hooks.StopFailure = structuredClone(hooks.hooks.Stop);