@awak-app/simy-cli 0.5.43 → 0.5.44

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@awak-app/simy-cli",
3
- "version": "0.5.43",
3
+ "version": "0.5.44",
4
4
  "description": "Local SIMY executor for non-coding Local Tasks and Agentic Loop runs.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -37,7 +37,7 @@
37
37
  "test:e2e:console:real-provider": "node ./scripts/real-provider-console-e2e.js",
38
38
  "test:e2e:console:fullstack": "node ./scripts/fullstack-cli-smoke.js",
39
39
  "check": "npm run check:auto-update && node --check ./src/desktop-executor.js && node --check ./src/local-task.js && node --check ./scripts/desktop-executor-e2e.js && node --check ./scripts/pre-guardrail-e2e.js && node --check ./scripts/fixtures/fake-desktop-provider.js && npm run check:syntax && npm run check:session-supervision-syntax && npm run check:real-provider-syntax && npm test && npm run check:package",
40
- "check:session-supervision-syntax": "node --check ./src/session-supervision/index.js && node --check ./src/session-supervision/codex-app-server.js && node --check ./src/session-supervision/claude-sessions.js && node --check ./src/session-supervision/original-session-target.js && node --check ./src/session-supervision/action-association.js && node --check ./src/session-supervision/action-association-host.js && node --check ./src/session-supervision/action-association-api.js && node --check ./src/codex-plugins/simy/hooks/action-association.mjs",
40
+ "check:session-supervision-syntax": "node --check ./src/session-supervision/index.js && node --check ./src/session-supervision/codex-app-server.js && node --check ./src/session-supervision/claude-sessions.js && node --check ./src/session-supervision/original-session-target.js && node --check ./src/session-supervision/action-association.js && node --check ./src/session-supervision/action-association-host.js && node --check ./src/session-supervision/action-association-api.js && node --check ./src/session-supervision/association-store.js && node --check ./src/session-supervision/action-deadline.js && node --check ./src/session-supervision/action-deadline-store.js && node --check ./src/codex-plugins/simy/hooks/action-association.mjs",
41
41
  "check:auto-update": "node --check ./src/auto-update.js && node --check ./scripts/auto-update-e2e.js && npm run check:sqm",
42
42
  "check:workflow-observation-sender": "node --check ./src/workflow-observation-sender.js",
43
43
  "check:sqm": "node --check ./src/sqm/canonical.js && node --check ./src/sqm/validation.js && node --check ./src/sqm/reliability.js && node --check ./src/sqm/repository.js && node --check ./src/sqm/checkers.js && node --check ./src/sqm/bundle-store.js && node --check ./src/sqm/proof.js && node --check ./src/sqm/evidence-client.js && node --check ./src/sqm/session.js && node --check ./src/sqm/index.js && node --check ./src/sqm/command.js && node --check ./src/sqm/agent-skill.js && node --check ./src/sqm/workflow-onboarding.js && node --check ./src/sqm/workflow-update.js && node --check ./src/sqm/review-worker.js && node --check ./scripts/sqm-complete-loop-e2e.js && node --check ./scripts/sqm-local-review-e2e.js",
@@ -13,7 +13,7 @@ import {
13
13
  } from "./durable-local-task-steps-contract.js";
14
14
  import { buildExecutionCapabilityContract } from "./execution-capability-contract.js";
15
15
 
16
- export const CLI_VERSION = "0.5.43";
16
+ export const CLI_VERSION = "0.5.44";
17
17
  export const CLI_API_CONTRACT_VERSION = 10;
18
18
 
19
19
  export function withCliContract(
@@ -357,6 +357,10 @@ export async function startDelivery({ root = rootDirectory(), provider, session,
357
357
  if (previous.run) throw new Error('This session already has a Delivery Loop. Resume or stop it; budgets cannot be reset by starting again.');
358
358
  const managedState = managedSpec(managed, session, provider, previous.process, admitted ? admission : null, now);
359
359
  if (autoContinueEpoch && managedState) managedState.autoContinueEpoch = autoContinueEpoch;
360
+ if (managedState && previous.associationTaskId) {
361
+ managedState.taskId = previous.associationTaskId;
362
+ if (previous.actionAssociation) managedState.actionAssociation = structuredClone(previous.actionAssociation);
363
+ }
360
364
  if (liveHookAdmission && managedState) managedState.liveHookOwner = { pid: liveHookAdmission.pid,
361
365
  identity: liveHookAdmission.identity, hookStartedAt: liveHookAdmission.hookStartedAt };
362
366
  const waiting = managedState?.decision?.outcome === 'wait';
@@ -1,3 +1,4 @@
1
+ import { createActionDeadlineService } from './session-supervision/action-deadline.js';
1
2
  import { deliveryHookStatus, openHookSetup } from "./delivery-hooks/desktop.js";
2
3
  import { controlDelivery, verifyDelivery } from "./codex-plugins/simy/hooks/delivery-loop.mjs";
3
4
  import { createManagedSupervisor } from "./delivery-hooks/supervisor.js";
@@ -48,8 +49,9 @@ export async function startDesktopHost({ input = process.stdin, output = process
48
49
  const lock = candidate ? null : await acquireDesktopProcessLock(root, { webOrigin });
49
50
  const supervisorToken = lock?.nonce || env.SIMY_DESKTOP_SUPERVISOR_TOKEN;
50
51
  let agent, updater, analysisManager, automaticContinuation, stopWorkflows = async () => {}, timer, closed = false, relay = null, publishing = null, publishAgain = false;
51
- const makeActionAssociations = () => (dependencies.createActionAssociationHost || createActionAssociationHost)({ root: join(root, "delivery-hooks"), sessionRoot: root, webOrigin });
52
+ const makeActionAssociations = () => (dependencies.createActionAssociationHost || createActionAssociationHost)({ root: join(root, "delivery-hooks"), sessionRoot: root, webOrigin, onChange: () => { if (!closed) setImmediate(() => { void publish().catch(() => {}); }); } });
52
53
  let actionAssociations = makeActionAssociations();
54
+ const deadlines = createActionDeadlineService({ root: join(root, "action-deadlines") });
53
55
  const managedSupervisor = (dependencies.createManagedSupervisor || createManagedSupervisor)({ root: join(root, "delivery-hooks"),
54
56
  beforeClaim: record => actionAssociations.prepareCached?.(record) || record });
55
57
  let shuttingDown = null;
@@ -105,7 +107,16 @@ export async function startDesktopHost({ input = process.stdin, output = process
105
107
  await actionAssociations.decorate(status.deliveryHooks);
106
108
  const discovery = refreshSessions();
107
109
  automaticContinuation?.tick(discovery.sessions);
108
- status.sessionSupervision = { ...discovery, sessions: discovery.sessions.map(candidate => automaticContinuation.project(analysisManager.project(candidate))) };
110
+ const sessions = discovery.sessions.map(candidate => automaticContinuation.project(analysisManager.project(candidate)));
111
+ await actionAssociations.decorateSessions?.(sessions);
112
+ for (const session of sessions) {
113
+ const context = await actionAssociations.deadlineContext?.(session);
114
+ if (context) session.actionDeadline = await deadlines.reconcile(context);
115
+ else session.actionDeadline = { status: 'unavailable', online: false, reminder: null };
116
+ const run = status.deliveryHooks.runs?.find(item => item.provider === session.provider && item.session === session.session);
117
+ if (run) run.actionDeadline = session.actionDeadline;
118
+ }
119
+ status.sessionSupervision = { ...discovery, sessions };
109
120
  if (!closed && !relay) emit({ type: "status", status });
110
121
  }).finally(() => { publishing = null; if (publishAgain && !closed) { publishAgain = false; setImmediate(() => { void publish().catch(() => {}); }); } });
111
122
  return publishing;
@@ -301,7 +312,8 @@ export async function startDesktopHost({ input = process.stdin, output = process
301
312
  await verifyDelivery({ root: join(root, "delivery-hooks"), provider: command.provider, session: command.session,
302
313
  taskId: command.taskId, criterionId: command.criterionId });
303
314
  } else if (["session_action_select", "session_action_unlink", "session_action_refresh"].includes(action)) {
304
- await actionAssociations.correct(command);
315
+ const candidate = await selectedSession(command);
316
+ await actionAssociations.correct(command, analysisManager.project(candidate));
305
317
  } else if (action === "session_set_auto_continue") {
306
318
  if (typeof command.enabled !== 'boolean') throw new Error('Choose whether automatic continuation is enabled.');
307
319
  if (command.enabled) {
@@ -316,6 +328,17 @@ export async function startDesktopHost({ input = process.stdin, output = process
316
328
  const fresh = await selectedSession(command);
317
329
  if (fresh.capabilities?.canAnalyze === true) await analysisManager.request(fresh);
318
330
  }
331
+ } else if (action === "session_deadline_decide") {
332
+ if (command.cancel === true) { emit({ type: "result", id: command.id, ok: true }); return; }
333
+ const candidate = await selectedSession(command);
334
+ await actionAssociations.correct({ ...command, action: 'session_action_refresh' }, analysisManager.project(candidate));
335
+ const projected = analysisManager.project(candidate);
336
+ await actionAssociations.decorateSessions?.([projected]);
337
+ const context = await actionAssociations.deadlineContext?.(projected);
338
+ if (!context || context.taskId !== command.taskId) throw new Error('Refresh this conversation before deciding its deadline.');
339
+ await deadlines.reconcile(context);
340
+ const result = await deadlines.decide(context, { ...command, requestId: command.requestId || command.decisionId });
341
+ if (result.status === 'conflict') throw Object.assign(new Error('Deadline changed; refresh before deciding.'), { code: 'conflict' });
319
342
  } else if (action === "session_track" || action === "session_untrack") {
320
343
  const candidate = await selectedSession(command);
321
344
  if (candidate.capabilities?.canMonitor !== true) throw new Error('Tracking is unavailable for this conversation.');
@@ -1,3 +1,4 @@
1
+ import { createOrdinaryAssociationStore } from './association-store.js';
1
2
  import { listRecords, updateRecord, TERMINAL, managedBudgetReason } from '../codex-plugins/simy/hooks/delivery-loop.mjs';
2
3
  import { createActionAssociationApi, ActionAssociationApiError } from './action-association-api.js';
3
4
  import { actionCandidateRequest, prepareActionAssociation, importActionBinding,
@@ -14,8 +15,9 @@ const hardFailure = code => ['unauthorized', 'unavailable', 'invalid_request', '
14
15
  // Association is optional metadata. Network failures never control the task's
15
16
  // work state; provider turns and their budgets remain owned by the supervisor.
16
17
  export function createActionAssociationHost({ root, sessionRoot, webOrigin, api = createActionAssociationApi({ webOrigin, sessionRoot }),
17
- now = Date.now, refreshMs = 60_000, list = listRecords } = {}) {
18
+ now = Date.now, refreshMs = 60_000, list = listRecords, onChange = () => {} } = {}) {
18
19
  const abort = new AbortController(), views = new Map();
20
+ const ordinary = createOrdinaryAssociationStore({ root, api, webOrigin, now, onChange });
19
21
  let auth = null, closed = false, running = null, serial = Promise.resolve();
20
22
  const enqueue = work => {
21
23
  const result = serial.then(work); serial = result.catch(() => {}); return result;
@@ -144,6 +146,34 @@ export function createActionAssociationHost({ root, sessionRoot, webOrigin, api
144
146
  }));
145
147
  }
146
148
  return {
149
+ async decorateSessions(sessions) {
150
+ await ordinary.decorate(sessions);
151
+ const records = await list(root);
152
+ const status = { runs: sessions.filter(item => records.some(record => record.provider === item.provider && record.session === item.session && record.run?.managed)).map(item => {
153
+ const record = records.find(record => record.provider === item.provider && record.session === item.session);
154
+ return { managed: true, taskId: record.run.managed.taskId, actionAssociation: record.run.managed.actionAssociation, candidate: item };
155
+ }) };
156
+ await this.decorate(status);
157
+ for (const run of status.runs) run.candidate.actionAssociation = run.actionAssociation;
158
+ return sessions;
159
+ },
160
+ async deadlineContext(candidate) {
161
+ const expected = await current(), record = (await list(root)).find(item => item.provider === candidate.provider && item.session === candidate.session);
162
+ const local = record?.run?.managed?.actionAssociation || record?.actionAssociation;
163
+ if (!expected || local?.authRevision !== expected || !candidate.actionAssociation?.canCorrect) return null;
164
+ const binding = candidate.actionAssociation.binding;
165
+ const fingerprint = value => JSON.stringify([value?.revision, value?.action?.id, value?.action?.revision, value?.action?.dueAt, value?.action?.timeZone, value?.action?.status]);
166
+ return { namespace: local.namespace, provider: candidate.provider, session: candidate.session, taskId: candidate.taskId, binding,
167
+ taskStatus: null, remainingWork: record.run?.managed?.sessionUnderstanding?.remaining || record.run?.managed?.remainingWork || candidate.analysis?.remaining || candidate.analysis?.remainingWork || null,
168
+ plan: record.run?.managed?.plan || candidate.analysis?.plan || null, blocker: record.run?.blocker || candidate.analysis?.blocker || null, online: true,
169
+ validate: async () => {
170
+ if (!await valid(expected)) return false;
171
+ const latest = (await list(root)).find(item => item.provider === candidate.provider && item.session === candidate.session);
172
+ const scoped = latest?.run?.managed?.actionAssociation || latest?.actionAssociation;
173
+ return (latest?.run?.managed?.taskId || latest?.associationTaskId) === candidate.taskId && scoped?.namespace === local.namespace && scoped?.authRevision === expected && fingerprint(scoped.binding) === fingerprint(binding);
174
+ } };
175
+
176
+ },
147
177
  async prepareCached(record) {
148
178
  const view = views.get(record?.run?.managed?.taskId), request = actionCandidateRequest(record);
149
179
  if (closed || !auth || !view || view.auth !== auth || view.error || !view.snapshot || !request || view.contextRevision !== request.context.revision || view.snapshot.contextRevision !== request.context.revision || view.namespace !== namespaceFor(webOrigin, view.snapshot.scope)) return record;
@@ -170,7 +200,7 @@ export function createActionAssociationHost({ root, sessionRoot, webOrigin, api
170
200
  status: view.error || local?.status || 'waiting_boundary',
171
201
  binding: view.namespace && local?.namespace === view.namespace ? publicBinding(local.binding) : null,
172
202
  candidates: (view.candidates || []).slice(0,5), canCorrect: !view.error,
173
- retryOn: local?.retryOn || null, truncated: view.candidateTruncated || false,
203
+ contextRevision: view.contextRevision, candidateRevision: view.snapshot?.candidateRevision || null, retryOn: local?.retryOn || null, truncated: view.candidateTruncated || false,
174
204
  };
175
205
  let bytes = Buffer.byteLength(JSON.stringify(run.actionAssociation));
176
206
  if (bytes > Math.min(remainingBytes,perRunBytes)) { run.actionAssociation.candidates = []; run.actionAssociation.truncated = true; bytes = Buffer.byteLength(JSON.stringify(run.actionAssociation)); }
@@ -183,7 +213,8 @@ export function createActionAssociationHost({ root, sessionRoot, webOrigin, api
183
213
  }
184
214
  return status;
185
215
  },
186
- correct(command) {
216
+ correct(command, candidate) {
217
+ if (candidate && candidate.binding?.mode !== 'managed' && candidate.binding?.mode !== 'pending_takeover') return ordinary.correct(command, candidate);
187
218
  return enqueue(async () => {
188
219
  const expected = await current(), record = await fresh(command);
189
220
  if (!expected) throw new ActionAssociationApiError('signed_out');
@@ -192,9 +223,11 @@ export function createActionAssociationHost({ root, sessionRoot, webOrigin, api
192
223
  const view = views.get(command.taskId);
193
224
  if (!await valid(expected) || !view?.namespace || view.error) throw new ActionAssociationApiError(view?.error || 'unavailable');
194
225
  if (command.action === 'session_action_refresh') { view.nextRefresh = 0; return; }
226
+ if (command.expectedContextRevision && command.expectedContextRevision !== view.contextRevision || command.expectedCandidateRevision && command.expectedCandidateRevision !== view.snapshot?.candidateRevision) throw new ActionAssociationApiError('conflict');
195
227
  const latest = await fresh(command), binding = latest?.run.managed.actionAssociation?.binding;
196
228
  if ((binding?.revision ?? null) !== command.expectedBindingRevision) throw new ActionAssociationApiError('conflict');
197
229
  const selected = command.action === 'session_action_select' ? view.snapshot?.candidates.find(item => item.id === command.actionId) || (!view.snapshot && /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(command.actionId || '') ? { id: command.actionId, revision: null } : null) : null;
230
+ if (selected && command.expectedActionRevision && command.expectedActionRevision !== selected.revision) throw new ActionAssociationApiError('conflict');
198
231
  if (command.action !== 'session_action_unlink' && !selected) throw new ActionAssociationApiError('invalid_request');
199
232
  const envelope = await api.saveBinding(command.taskId, {
200
233
  expectedRevision: command.expectedBindingRevision, contextRevision: view.contextRevision,
@@ -208,6 +241,6 @@ export function createActionAssociationHost({ root, sessionRoot, webOrigin, api
208
241
  view.error = null; view.nextRefresh = 0;
209
242
  });
210
243
  },
211
- async close() { closed = true; abort.abort(); await Promise.allSettled([serial, running]); views.clear(); },
244
+ async close() { closed = true; abort.abort(); await Promise.allSettled([serial, running, ordinary.close()]); views.clear(); },
212
245
  };
213
246
  }
@@ -0,0 +1,57 @@
1
+ import { mkdir, readFile, writeFile, rename, rm } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ import { createHash, randomUUID } from "node:crypto";
4
+
5
+ // All instances in a host share a queue; every transaction reloads disk so a
6
+ // newly created host sees durable decisions instead of an in-memory snapshot.
7
+ const queues = new Map();
8
+ export function createActionDeadlineStore({ root }) {
9
+ const directory = join(root, "action-deadlines");
10
+ const pathFor = (identity) =>
11
+ join(
12
+ directory,
13
+ `${createHash("sha256").update(JSON.stringify(identity)).digest("hex")}.json`,
14
+ );
15
+ const read = async (identity) => {
16
+ try {
17
+ return JSON.parse(await readFile(pathFor(identity), "utf8"));
18
+ } catch (error) {
19
+ if (error.code === "ENOENT") return null;
20
+ throw error;
21
+ }
22
+ };
23
+ return {
24
+ read,
25
+ transact(identity, update, validate = async () => true) {
26
+ const path = pathFor(identity);
27
+ const result = (queues.get(path) || Promise.resolve()).then(async () => {
28
+ const previous = await read(identity);
29
+ const assertCurrent = async () => {
30
+ if (!(await validate()))
31
+ throw Object.assign(new Error("Deadline context changed."), {
32
+ code: "conflict",
33
+ });
34
+ };
35
+ await assertCurrent();
36
+ const next = await update(previous);
37
+ if (next !== previous && next !== undefined) {
38
+ await mkdir(directory, { recursive: true, mode: 0o700 });
39
+ const temporary = `${path}.${randomUUID()}.tmp`;
40
+ await writeFile(temporary, JSON.stringify(next), { mode: 0o600 });
41
+ try {
42
+ await assertCurrent();
43
+ await rename(temporary, path);
44
+ } finally {
45
+ await rm(temporary, { force: true });
46
+ }
47
+ }
48
+ return next === undefined ? previous : next;
49
+ });
50
+ queues.set(
51
+ path,
52
+ result.catch(() => {}),
53
+ );
54
+ return result;
55
+ },
56
+ };
57
+ }
@@ -0,0 +1,310 @@
1
+ import { createHash } from "node:crypto";
2
+ import { createActionDeadlineStore } from "./action-deadline-store.js";
3
+
4
+ const terminal = new Set([
5
+ "completed",
6
+ "complete",
7
+ "done",
8
+ "cancelled",
9
+ "canceled",
10
+ "failed",
11
+ "archived",
12
+ "deleted",
13
+ ]);
14
+ const hash = (value) =>
15
+ createHash("sha256").update(JSON.stringify(value)).digest("hex");
16
+ const identity = (context) => {
17
+ const result = [
18
+ context.namespace,
19
+ context.provider,
20
+ context.session,
21
+ context.taskId,
22
+ ];
23
+ if (result.some((value) => typeof value !== "string" || !value))
24
+ throw new Error("A scoped session and task identity is required.");
25
+ return result;
26
+ };
27
+ // A date-only Action is due at the next local midnight, including DST days.
28
+ // Explicit timestamps must carry their own offset; host local timezone is never used.
29
+ export function actionDueInstant(dueAt, timeZone) {
30
+ if (typeof dueAt !== "string") return null;
31
+ try {
32
+ if (/^\d{4}-\d{2}-\d{2}$/.test(dueAt)) {
33
+ if (typeof timeZone !== "string" || !timeZone) return null;
34
+ const format = new Intl.DateTimeFormat("en-CA", {
35
+ timeZone,
36
+ year: "numeric",
37
+ month: "2-digit",
38
+ day: "2-digit",
39
+ });
40
+ const start = Date.parse(`${dueAt}T00:00:00Z`);
41
+ if (
42
+ !Number.isFinite(start) ||
43
+ new Date(start).toISOString().slice(0, 10) !== dueAt
44
+ )
45
+ return null;
46
+ const localDate = (instant) => {
47
+ const parts = Object.fromEntries(
48
+ format.formatToParts(instant).map((part) => [part.type, part.value]),
49
+ );
50
+ return `${parts.year}-${parts.month}-${parts.day}`;
51
+ };
52
+ let low = start - 36 * 3600000,
53
+ high = start + 60 * 3600000;
54
+ while (high - low > 1) {
55
+ const mid = Math.floor((low + high) / 2);
56
+ if (localDate(mid) <= dueAt) low = mid;
57
+ else high = mid;
58
+ }
59
+ return high;
60
+ }
61
+ if (!/^\d{4}-\d{2}-\d{2}T.*(?:Z|[+-]\d{2}:\d{2})$/i.test(dueAt))
62
+ return null;
63
+ const calendar = dueAt.slice(0, 10),
64
+ calendarInstant = Date.parse(`${calendar}T00:00:00Z`);
65
+ if (
66
+ !Number.isFinite(calendarInstant) ||
67
+ new Date(calendarInstant).toISOString().slice(0, 10) !== calendar
68
+ )
69
+ return null;
70
+ const instant = Date.parse(dueAt);
71
+ return Number.isFinite(instant) ? instant : null;
72
+ } catch {
73
+ return null;
74
+ }
75
+ }
76
+ const options = [
77
+ {
78
+ id: "keep_scope",
79
+ title: "Keep the current scope",
80
+ description: "Continue the remaining work with the current deadline.",
81
+ },
82
+ {
83
+ id: "revise_deadline",
84
+ title: "Propose a new deadline",
85
+ description: "Discuss a new deadline before changing the Action.",
86
+ },
87
+ {
88
+ id: "reduce_scope",
89
+ title: "Propose a smaller scope",
90
+ description: "Discuss what to defer before changing the Action.",
91
+ },
92
+ ];
93
+ export function createActionDeadlineService({
94
+ root,
95
+ now = Date.now,
96
+ store = createActionDeadlineStore({ root }),
97
+ reminderLeadMs = 24 * 3600000,
98
+ } = {}) {
99
+ const transact = async (context, update) => {
100
+ try {
101
+ return await store.transact(
102
+ identity(context),
103
+ update,
104
+ context.validate || context.isCurrent,
105
+ );
106
+ } catch (error) {
107
+ if (error.code === "conflict")
108
+ return {
109
+ status: "conflict",
110
+ reminder: null,
111
+ decision: null,
112
+ httpStatus: 409,
113
+ };
114
+ throw error;
115
+ }
116
+ };
117
+ const view = (state, context) => ({
118
+ ...state,
119
+ online: context.online !== false,
120
+ remoteApplied: false,
121
+ persistence: "local",
122
+ options,
123
+ remainingWork: context.remainingWork ?? null,
124
+ blocker: context.blocker ?? null,
125
+ plan: context.plan ?? null,
126
+ recommendation: {
127
+ optionId: context.blocker ? "revise_deadline" : "keep_scope",
128
+ rationale: context.blocker
129
+ ? "Resolve the blocker and discuss a realistic deadline."
130
+ : "Continue the known remaining work; no completion forecast is available.",
131
+ },
132
+ });
133
+ async function reconcile(context) {
134
+ const key = identity(context),
135
+ action = context.binding?.action;
136
+ const dueInstant = actionDueInstant(action?.dueAt, action?.timeZone);
137
+ const bindingRevision =
138
+ context.binding?.revision ?? context.bindingRevision ?? null;
139
+ const fingerprint = hash([
140
+ action?.id,
141
+ action?.revision,
142
+ bindingRevision,
143
+ action?.dueAt,
144
+ action?.timeZone,
145
+ action?.status,
146
+ terminal.has(context.taskStatus),
147
+ ]);
148
+ const state = await transact(context, (previous) => {
149
+ if (context.online === false) return previous;
150
+ // Tombstones retain the last authoritative revision, so old snapshots
151
+ // cannot clear a newer binding or resurrect a previously unlinked Action.
152
+ if (
153
+ previous?.bindingRevision != null &&
154
+ (bindingRevision == null
155
+ ? !(context.validate || context.isCurrent)
156
+ : previous.bindingRevision > bindingRevision)
157
+ )
158
+ return previous;
159
+ if (previous?.fingerprint === fingerprint) return previous;
160
+ const revision = (previous?.revision || 0) + 1;
161
+ const base = {
162
+ identity: key,
163
+ fingerprint,
164
+ revision,
165
+ bindingRevision: bindingRevision ?? previous?.bindingRevision ?? null,
166
+ actionId: action?.id ?? null,
167
+ actionRevision: action?.revision ?? null,
168
+ dueAt: action?.dueAt ?? null,
169
+ timeZone: action?.timeZone ?? null,
170
+ reminder: null,
171
+ decision: null,
172
+ };
173
+ if (
174
+ !action ||
175
+ terminal.has(action.status) ||
176
+ terminal.has(context.taskStatus)
177
+ )
178
+ return { ...base, status: "hidden" };
179
+ if (action.dueAt == null || action.dueAt === "") return { ...base, status: "hidden" };
180
+ if (dueInstant === null)
181
+ return {
182
+ ...base,
183
+ status: "unavailable",
184
+ reason: "The Action deadline needs a valid date and timezone.",
185
+ };
186
+ return {
187
+ ...base,
188
+ dueInstant,
189
+ status: "upcoming",
190
+ reminder: {
191
+ key: hash([...key, fingerprint]),
192
+ revision,
193
+ dueAt: action.dueAt,
194
+ timeZone: action.timeZone,
195
+ dueInstant,
196
+ },
197
+ };
198
+ });
199
+ return project(state, context);
200
+ }
201
+ function project(state, context) {
202
+ if (!state)
203
+ return view(
204
+ {
205
+ status: context.online === false ? "unavailable" : "hidden",
206
+ reminder: null,
207
+ decision: null,
208
+ },
209
+ context,
210
+ );
211
+ const status =
212
+ context.online === false
213
+ ? "unavailable"
214
+ : state.decision
215
+ ? "decided"
216
+ : state.reminder && now() >= state.dueInstant
217
+ ? "overdue"
218
+ : state.reminder && now() >= state.dueInstant - reminderLeadMs
219
+ ? "due"
220
+ : state.status;
221
+ return view({ ...state, status }, context);
222
+ }
223
+ // Saving a local choice does not edit the remote Action deadline or scope.
224
+ async function decide(context, payload) {
225
+ const key = identity(context);
226
+ if (payload.cancel === true)
227
+ return { ...(await read(context)), cancelled: true };
228
+ let conflict = false;
229
+ const state = await transact(context, (previous) => {
230
+ const action = context.binding?.action;
231
+ if (
232
+ context.online === false ||
233
+ !previous?.reminder ||
234
+ terminal.has(context.taskStatus) ||
235
+ terminal.has(action?.status) ||
236
+ previous.reminder.key !== payload.reminderKey ||
237
+ previous.reminder.revision !== payload.reminderRevision ||
238
+ previous.actionId !== action?.id ||
239
+ (payload.actionId && payload.actionId !== action.id) ||
240
+ previous.actionRevision !== action?.revision ||
241
+ previous.actionRevision !== payload.expectedActionRevision ||
242
+ previous.bindingRevision !== context.binding?.revision ||
243
+ previous.bindingRevision !== payload.expectedBindingRevision ||
244
+ previous.dueAt !== action?.dueAt ||
245
+ previous.timeZone !== action?.timeZone
246
+ ) {
247
+ conflict = true;
248
+ return previous;
249
+ }
250
+ const requestId = payload.requestId || payload.decisionId;
251
+ if (
252
+ typeof requestId !== "string" ||
253
+ !requestId ||
254
+ !options.some((option) => option.id === payload.choice)
255
+ ) {
256
+ conflict = true;
257
+ return previous;
258
+ }
259
+ const payloadRevision = hash([
260
+ payload.actionId ?? null,
261
+ payload.reminderKey,
262
+ payload.reminderRevision,
263
+ payload.expectedActionRevision,
264
+ payload.expectedBindingRevision,
265
+ payload.choice,
266
+ payload.expectedDecisionRevision ?? null,
267
+ ]);
268
+ if (Object.hasOwn(previous.requests || {}, requestId)) {
269
+ if (previous.requests[requestId].payloadRevision !== payloadRevision)
270
+ conflict = true;
271
+ return previous;
272
+ }
273
+ if (previous.decision?.requestId === requestId) {
274
+ if (previous.decision.choice !== payload.choice) conflict = true;
275
+ return previous;
276
+ }
277
+ if (
278
+ previous.decision &&
279
+ payload.expectedDecisionRevision !== previous.decision.revision
280
+ ) {
281
+ conflict = true;
282
+ return previous;
283
+ }
284
+ return {
285
+ ...previous,
286
+ status: "decided",
287
+ requests: {
288
+ ...previous.requests,
289
+ [requestId]: { choice: payload.choice, payloadRevision },
290
+ },
291
+ decision: {
292
+ id: requestId,
293
+ requestId,
294
+ choice: payload.choice,
295
+ revision: (previous.decision?.revision || 0) + 1,
296
+ reminderKey: previous.reminder.key,
297
+ createdAt: now(),
298
+ remoteApplied: false,
299
+ },
300
+ };
301
+ });
302
+ return conflict
303
+ ? { ...project(state, context), status: "conflict", httpStatus: 409 }
304
+ : project(state, context);
305
+ }
306
+ async function read(context) {
307
+ return project(await store.read(identity(context)), context);
308
+ }
309
+ return { reconcile, read, decide };
310
+ }
@@ -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,