@awak-app/simy-cli 0.7.16 → 0.7.18
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/README.md +180 -26
- package/package.json +3 -2
- package/src/agent.js +128 -7
- package/src/bounded-local-task-subtask-pool.js +32 -6
- package/src/bounded-local-task-subtasks-contract.js +1 -1
- package/src/cli-contract.js +1 -1
- package/src/codex-plugins/simy/.codex-plugin/plugin.json +2 -2
- package/src/codex-plugins/simy/hooks/README.md +8 -3
- package/src/codex-plugins/simy/hooks/delivery-loop.mjs +261 -67
- package/src/codex-plugins/simy/hooks/hooks.json +1 -1
- package/src/codex-plugins/simy/hooks/loop-kernel/conformance.json +136 -0
- package/src/codex-plugins/simy/hooks/loop-kernel/index.mjs +42 -0
- package/src/codex-plugins/simy/hooks/loop-kernel/outcome-review.mjs +31 -0
- package/src/codex-plugins/simy/hooks/loop-kernel/pin.json +6 -0
- package/src/codex-plugins/simy/hooks/loop-kernel/policy.json +141 -0
- package/src/codex-plugins/simy/hooks/loop-kernel/task-pool.mjs +34 -0
- package/src/codex-plugins/simy/hooks/loop-kernel/work-frontier.mjs +132 -0
- package/src/codex-plugins/simy/hooks/loop-kernel.mjs +6 -0
- package/src/codex-plugins/simy/hooks/outcome-progress.mjs +15 -3
- package/src/codex-plugins/simy/hooks/purpose-completion-contract.mjs +10 -0
- package/src/codex-plugins/simy/hooks/purpose-evidence.mjs +8 -6
- package/src/codex-plugins/simy/hooks/result-cycle.mjs +32 -6
- package/src/codex-plugins/simy/hooks/work-frontier.mjs +2 -0
- package/src/codex-plugins/simy/skills/diagnose/SKILL.md +10 -4
- package/src/coding-run-recovery.js +4 -2
- package/src/delivery-hooks/claude-workflow.md +10 -7
- package/src/delivery-hooks/command.js +45 -10
- package/src/delivery-hooks/consult.js +60 -0
- package/src/delivery-hooks/diagnosis.js +2 -2
- package/src/delivery-hooks/legacy-hook-routing.js +46 -8
- package/src/delivery-hooks/local-runtime.js +50 -1
- package/src/delivery-hooks/local-supervisor.js +1 -1
- package/src/delivery-hooks/repair-continuation.js +80 -0
- package/src/delivery-hooks/repair.js +164 -0
- package/src/delivery-hooks/supervisor-service.js +3 -1
- package/src/delivery-hooks/supervisor.js +88 -12
- package/src/desktop-access.js +2 -1
- package/src/desktop-host.js +62 -2
- package/src/durable-local-task-worker.js +372 -58
- package/src/execution-capability-contract.js +15 -1
- package/src/index.js +3 -3
- package/src/local-photos-read.swift +33 -0
- package/src/local-photos.js +47 -0
- package/src/local-task-attachment-store.js +31 -0
- package/src/local-task-destination.js +174 -0
- package/src/local-task.js +119 -7
- package/src/native-delegation-contract.js +68 -0
- package/src/native-provider-identity.js +60 -0
- package/src/orchestrator/budget.js +22 -11
- package/src/orchestrator/independent-audit.js +9 -18
- package/src/orchestrator/loop.js +27 -59
- package/src/orchestrator/recovery.js +32 -2
- package/src/provider-delegation.js +336 -0
- package/src/runner.js +2 -1
- package/src/session-supervision/artifact-observer.js +138 -0
- package/src/session-supervision/claude-sessions.js +5 -3
- package/src/session-supervision/codex-app-server.js +20 -5
- package/src/session-supervision/completion-artifacts.js +44 -0
- package/src/session-supervision/consistency-auditor.js +8 -11
- package/src/session-supervision/continuation-analysis.js +46 -28
- package/src/session-supervision/discovery-window.js +1 -1
- package/src/session-supervision/final-response-recovery.js +5 -3
- package/src/session-supervision/index.js +25 -8
- package/src/session-supervision/purpose-recovery.js +12 -3
- package/src/session-supervision/purpose-verification.js +9 -57
- package/src/session-supervision/recovery-frontier.js +55 -0
- package/src/session-supervision/remote-hosts.js +1 -1
- package/src/session-supervision/stop-notifications.js +8 -8
- package/src/sqm/agent-skill.js +1 -1
- package/src/sqm/checkers.js +12 -6
- package/src/sqm/claude-plugin-update.js +22 -8
- package/src/sqm/claude-plugin.js +4 -0
- package/src/sqm/local-tests.js +2 -2
- package/src/sqm/plugin-cache.js +67 -0
- package/src/sqm/proof.js +25 -0
- package/src/sqm/report.js +1 -0
- package/src/sqm/workflow-onboarding.js +3 -39
- package/src/sqm/workflow-update.js +17 -6
- package/src/team-status-fence-store.js +32 -0
- package/src/team-threads.js +478 -0
|
@@ -7,6 +7,7 @@ import { pinnedWorkflowSource } from "../delivery-hooks/local-runtime.js";
|
|
|
7
7
|
|
|
8
8
|
import { runProviderCommand } from "./provider-command.js";
|
|
9
9
|
import { refreshClaudePlugin } from "./claude-plugin-update.js";
|
|
10
|
+
import { pluginCacheMatches } from "./plugin-cache.js";
|
|
10
11
|
|
|
11
12
|
import { resolveBackendExecutable } from "../backend-executable.js";
|
|
12
13
|
import { inspectSqmAgentSkill, installSqmAgentSkill, retireLegacySqmGate } from "./agent-skill.js";
|
|
@@ -14,13 +15,18 @@ import { inspectSqmAgentSkill, installSqmAgentSkill, retireLegacySqmGate } from
|
|
|
14
15
|
|
|
15
16
|
const CHECK_INTERVAL_MS = 60 * 60 * 1000;
|
|
16
17
|
|
|
17
|
-
export function startWorkflowUpdates({ enabled, sync = syncInstalledWorkflows, intervalMs = CHECK_INTERVAL_MS,
|
|
18
|
+
export function startWorkflowUpdates({ enabled, installMissing = true, sync = syncInstalledWorkflows, intervalMs = CHECK_INTERVAL_MS,
|
|
18
19
|
errorLogger = console.error } = {}) {
|
|
19
20
|
let pending;
|
|
20
21
|
let stopped = !enabled;
|
|
21
22
|
const check = () => {
|
|
22
23
|
if (stopped || pending) return;
|
|
23
|
-
pending = Promise.resolve().then(sync).
|
|
24
|
+
pending = Promise.resolve().then(() => sync({ installMissing })).then((result) => {
|
|
25
|
+
if (result?.refreshPending) {
|
|
26
|
+
const details = result.refreshFailures?.map(({ provider, message }) => `${provider}: ${message}`).join('; ');
|
|
27
|
+
errorLogger(`SIMY workflow sources are prepared, but plugin cache refresh is pending; will retry later.${details ? ` ${details}` : ''}`);
|
|
28
|
+
}
|
|
29
|
+
}).catch((error) => {
|
|
24
30
|
errorLogger(`SIMY workflow update failed; will retry later: ${error.message}`);
|
|
25
31
|
}).finally(() => { pending = null; });
|
|
26
32
|
};
|
|
@@ -79,16 +85,20 @@ export async function syncInstalledWorkflows({ home = homedir(), installMissing
|
|
|
79
85
|
if (platforms.length) status = await install({ home, platforms });
|
|
80
86
|
const results = {};
|
|
81
87
|
const failures = [];
|
|
88
|
+
const refreshFailures = [];
|
|
82
89
|
for (const [provider, refresh] of [
|
|
83
90
|
["codex", () => refreshCodexPlugin({ status, home, codexHome, resolveExecutable, run })],
|
|
84
91
|
["claude", () => refreshClaudePlugin({ status, home, claudeHome, resolveExecutable, run })],
|
|
85
92
|
]) {
|
|
86
93
|
try { results[provider] = await refresh(); }
|
|
87
|
-
catch (error) {
|
|
94
|
+
catch (error) {
|
|
95
|
+
failures.push(error);
|
|
96
|
+
refreshFailures.push({ provider, message: error.message });
|
|
97
|
+
}
|
|
88
98
|
}
|
|
89
99
|
// Explicit desktop setup prepares both sources even when a provider CLI is
|
|
90
100
|
// absent. Cache refresh remains retryable; no provider is enabled here.
|
|
91
|
-
if (failures.length && installMissing) return { outcome: "prepared", refreshPending: true };
|
|
101
|
+
if (failures.length && installMissing) return { outcome: "prepared", refreshPending: true, refreshFailures, providers: results };
|
|
92
102
|
if (failures.length) throw new AggregateError(failures, failures.map((error) => error.message).join("; "));
|
|
93
103
|
if (results.claude.outcome === "updated") return { ...results.claude, providers: results };
|
|
94
104
|
if (results.codex.outcome !== "not_enabled") return results.codex;
|
|
@@ -123,8 +133,9 @@ async function refreshCodexPlugin({ status, home, codexHome, resolveExecutable,
|
|
|
123
133
|
const cacheManifest = path.join(codexHome, "plugins", "cache", marketplace.name,
|
|
124
134
|
"simy", status.codex_plugin.version, ".codex-plugin", "plugin.json");
|
|
125
135
|
const cacheCurrent = async () => {
|
|
126
|
-
try { return JSON.parse(await readFile(cacheManifest, "utf8")).version === status.codex_plugin.version
|
|
127
|
-
|
|
136
|
+
try { return JSON.parse(await readFile(cacheManifest, "utf8")).version === status.codex_plugin.version &&
|
|
137
|
+
await pluginCacheMatches(status.codex_plugin.path, path.dirname(path.dirname(cacheManifest))); }
|
|
138
|
+
catch (error) { if (['ENOENT', 'ENOTDIR'].includes(error.code) || error instanceof SyntaxError) return false; throw error; }
|
|
128
139
|
};
|
|
129
140
|
if (installed.version === status.codex_plugin.version && await cacheCurrent()) return { outcome: "current" };
|
|
130
141
|
await run(executable, ["plugin", "add", selector, "--json"], options);
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { mkdir, open, readFile, rename, rm } from 'node:fs/promises';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { randomUUID } from 'node:crypto';
|
|
4
|
+
// Only an opaque device/account/org/thread digest and a pending OFF version
|
|
5
|
+
// persist. Conversation text and provider identifiers never enter this store.
|
|
6
|
+
export function createTeamStatusFenceStore(root) {
|
|
7
|
+
const directory = join(root, 'team-status-revocations');
|
|
8
|
+
const path = key => {
|
|
9
|
+
if (!/^[a-f0-9]{64}$/.test(key)) throw new Error('Invalid revocation scope');
|
|
10
|
+
return join(directory, `${key}.json`);
|
|
11
|
+
};
|
|
12
|
+
return {
|
|
13
|
+
async read(key) {
|
|
14
|
+
let raw;
|
|
15
|
+
try { raw = await readFile(path(key), 'utf8'); } catch (error) { if (error.code === 'ENOENT') return null; throw error; }
|
|
16
|
+
const value = JSON.parse(raw);
|
|
17
|
+
if (value?.schemaVersion !== 1 || !Number.isSafeInteger(value.expectedVersion) || value.expectedVersion < 0) throw new Error('Invalid revocation fence');
|
|
18
|
+
return { expectedVersion: value.expectedVersion };
|
|
19
|
+
},
|
|
20
|
+
async write(key, expectedVersion) {
|
|
21
|
+
await mkdir(directory, { recursive: true, mode: 0o700 });
|
|
22
|
+
const destination = path(key), temporary = `${destination}.${randomUUID()}.tmp`;
|
|
23
|
+
try {
|
|
24
|
+
const handle = await open(temporary, 'wx', 0o600);
|
|
25
|
+
try { await handle.writeFile(JSON.stringify({schemaVersion:1,expectedVersion})); await handle.sync(); } finally { await handle.close(); }
|
|
26
|
+
await rename(temporary, destination);
|
|
27
|
+
if (process.platform !== 'win32') { const parent = await open(directory, 'r'); try { await parent.sync(); } finally { await parent.close(); } }
|
|
28
|
+
} finally { await rm(temporary, {force:true}); }
|
|
29
|
+
},
|
|
30
|
+
async clear(key) { await rm(path(key), {force:true}); },
|
|
31
|
+
};
|
|
32
|
+
}
|
|
@@ -0,0 +1,478 @@
|
|
|
1
|
+
function profileAvatar(value) {
|
|
2
|
+
if (typeof value !== "string" || value.length > 2048) return {};
|
|
3
|
+
try {
|
|
4
|
+
const url = new URL(value);
|
|
5
|
+
if (url.protocol !== "https:" || !url.hostname || url.username || url.password) return {};
|
|
6
|
+
return { avatarUrl: url.toString() };
|
|
7
|
+
} catch { return {}; }
|
|
8
|
+
}
|
|
9
|
+
import { createHash } from "node:crypto";
|
|
10
|
+
import { sanitizedText } from "./session-supervision/conversation-context.js";
|
|
11
|
+
const digest = (value) => createHash("sha256").update(value).digest("hex");
|
|
12
|
+
const keyPattern = /^[a-f0-9]{64}$/;
|
|
13
|
+
const uuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
14
|
+
const empty = () => ({
|
|
15
|
+
state: "loading",
|
|
16
|
+
members: [],
|
|
17
|
+
threads: [],
|
|
18
|
+
own: [],
|
|
19
|
+
detail: null,
|
|
20
|
+
pendingStops: [],
|
|
21
|
+
memberId: null,
|
|
22
|
+
threadsTruncated: false,
|
|
23
|
+
});
|
|
24
|
+
const invalid = () => {
|
|
25
|
+
throw new Error("Shared conversations unavailable");
|
|
26
|
+
};
|
|
27
|
+
export const teamThreadKey = (scope, candidate) =>
|
|
28
|
+
digest(
|
|
29
|
+
JSON.stringify([scope.fenceKey, candidate.provider, candidate.session]),
|
|
30
|
+
);
|
|
31
|
+
function projectSummary(row) {
|
|
32
|
+
if (
|
|
33
|
+
!uuid.test(row?.memberId) ||
|
|
34
|
+
!keyPattern.test(row?.threadKey) ||
|
|
35
|
+
(row.parentThreadKey != null && (!keyPattern.test(row.parentThreadKey) || row.parentThreadKey === row.threadKey)) ||
|
|
36
|
+
typeof row.title !== "string" ||
|
|
37
|
+
!row.title.trim() ||
|
|
38
|
+
row.title.length > 240 ||
|
|
39
|
+
!["running", "needs_attention", "unknown"].includes(row.state) ||
|
|
40
|
+
!Number.isFinite(Date.parse(row.observedAt))
|
|
41
|
+
)
|
|
42
|
+
invalid();
|
|
43
|
+
return {
|
|
44
|
+
memberId: row.memberId,
|
|
45
|
+
threadKey: row.threadKey,
|
|
46
|
+
parentThreadKey: row.parentThreadKey ?? null,
|
|
47
|
+
title: sanitizedText(row.title),
|
|
48
|
+
state: row.state,
|
|
49
|
+
observedAt: row.observedAt,
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
export function projectThreadContext(result, org) {
|
|
53
|
+
if (
|
|
54
|
+
result?.schemaVersion !== 1 ||
|
|
55
|
+
result.organizationId !== org ||
|
|
56
|
+
!Array.isArray(result.members) ||
|
|
57
|
+
result.members.length > 2000 ||
|
|
58
|
+
!Array.isArray(result.threads) ||
|
|
59
|
+
result.threads.length > 200 ||
|
|
60
|
+
!Array.isArray(result.own) ||
|
|
61
|
+
result.own.length > 200
|
|
62
|
+
)
|
|
63
|
+
invalid();
|
|
64
|
+
const members = result.members.map((row) => {
|
|
65
|
+
if (
|
|
66
|
+
!uuid.test(row?.id) ||
|
|
67
|
+
typeof row.label !== "string" ||
|
|
68
|
+
!row.label.trim() ||
|
|
69
|
+
row.label.length > 240
|
|
70
|
+
)
|
|
71
|
+
invalid();
|
|
72
|
+
return { id: row.id, label: sanitizedText(row.label), ...profileAvatar(row.avatarUrl) };
|
|
73
|
+
});
|
|
74
|
+
const own = result.own.map((row) => {
|
|
75
|
+
if (
|
|
76
|
+
!keyPattern.test(row?.threadKey) ||
|
|
77
|
+
!Number.isSafeInteger(row.version) ||
|
|
78
|
+
row.version < 0 ||
|
|
79
|
+
typeof row.enabled !== "boolean"
|
|
80
|
+
)
|
|
81
|
+
invalid();
|
|
82
|
+
return {
|
|
83
|
+
threadKey: row.threadKey,
|
|
84
|
+
version: row.version,
|
|
85
|
+
enabled: row.enabled,
|
|
86
|
+
};
|
|
87
|
+
});
|
|
88
|
+
const threads = result.threads.map(projectSummary);
|
|
89
|
+
if (
|
|
90
|
+
new Set(members.map((x) => x.id)).size !== members.length ||
|
|
91
|
+
new Set(own.map((x) => x.threadKey)).size !== own.length
|
|
92
|
+
)
|
|
93
|
+
invalid();
|
|
94
|
+
return { members, own, threads };
|
|
95
|
+
}
|
|
96
|
+
export function createTeamThreads({
|
|
97
|
+
readScope,
|
|
98
|
+
request,
|
|
99
|
+
fenceStore,
|
|
100
|
+
readContext = async (...args) => (await import("./session-supervision/continuation-analysis.js")).readContinuationContext(...args),
|
|
101
|
+
onChange = () => {},
|
|
102
|
+
now = Date.now,
|
|
103
|
+
pollMs = 30000,
|
|
104
|
+
}) {
|
|
105
|
+
let value = empty(),
|
|
106
|
+
scopeKey,
|
|
107
|
+
generation = 0,
|
|
108
|
+
controller,
|
|
109
|
+
busy = false,
|
|
110
|
+
closed = false,
|
|
111
|
+
lastPoll = -Infinity,
|
|
112
|
+
candidates = [],
|
|
113
|
+
selected = null,
|
|
114
|
+
memberFilter = null,
|
|
115
|
+
pending = new Map(),
|
|
116
|
+
publicationGrants = new Map(),
|
|
117
|
+
storageFailed = false,
|
|
118
|
+
lastAllowed = false;
|
|
119
|
+
function scope() {
|
|
120
|
+
const current = readScope();
|
|
121
|
+
if (scopeKey !== current.key || (lastAllowed && !current.allowed)) {
|
|
122
|
+
controller?.abort();
|
|
123
|
+
generation++;
|
|
124
|
+
const sameIdentity = scopeKey === current.key;
|
|
125
|
+
scopeKey = current.key;
|
|
126
|
+
const own = sameIdentity && current.authenticated ? value.own : [];
|
|
127
|
+
value = { ...empty(), own };
|
|
128
|
+
selected = null;
|
|
129
|
+
memberFilter = null;
|
|
130
|
+
if (!sameIdentity) pending = new Map();
|
|
131
|
+
publicationGrants.clear();
|
|
132
|
+
lastPoll = -Infinity;
|
|
133
|
+
}
|
|
134
|
+
lastAllowed = current.allowed;
|
|
135
|
+
if (!current.allowed) value.state = "unavailable";
|
|
136
|
+
return current;
|
|
137
|
+
}
|
|
138
|
+
const snapshot = () => {
|
|
139
|
+
scope();
|
|
140
|
+
return structuredClone({ ...value, pendingStops: [...pending.keys()], confirmationRequired: value.own.filter(row => row.enabled && publicationGrants.get(row.threadKey) !== row.version).map(row => row.threadKey) });
|
|
141
|
+
};
|
|
142
|
+
const fenceKey = (current, key) => digest(`${current.fenceKey}:${key}`);
|
|
143
|
+
async function run(fn, allowOff = false) {
|
|
144
|
+
const current = scope();
|
|
145
|
+
if (
|
|
146
|
+
busy ||
|
|
147
|
+
closed ||
|
|
148
|
+
!current.authenticated ||
|
|
149
|
+
(!current.allowed && !allowOff)
|
|
150
|
+
)
|
|
151
|
+
return false;
|
|
152
|
+
busy = true;
|
|
153
|
+
controller = new AbortController();
|
|
154
|
+
const own = controller,
|
|
155
|
+
epoch = generation;
|
|
156
|
+
const valid = () =>
|
|
157
|
+
!closed &&
|
|
158
|
+
generation === epoch &&
|
|
159
|
+
readScope().key === current.key &&
|
|
160
|
+
readScope().authenticated &&
|
|
161
|
+
(readScope().allowed || allowOff) &&
|
|
162
|
+
!own.signal.aborted;
|
|
163
|
+
const send = async (body) => {
|
|
164
|
+
if (!valid()) invalid();
|
|
165
|
+
const result = await request(body, own.signal);
|
|
166
|
+
if (
|
|
167
|
+
!valid() ||
|
|
168
|
+
result?.schemaVersion !== 1 ||
|
|
169
|
+
result.organizationId !== current.organizationId
|
|
170
|
+
)
|
|
171
|
+
invalid();
|
|
172
|
+
return result;
|
|
173
|
+
};
|
|
174
|
+
try {
|
|
175
|
+
await fn({ current, send, valid, signal: own.signal });
|
|
176
|
+
return valid();
|
|
177
|
+
} catch {
|
|
178
|
+
if (valid()) {
|
|
179
|
+
value = {
|
|
180
|
+
...empty(),
|
|
181
|
+
own: value.own,
|
|
182
|
+
state: storageFailed ? "local_storage" : "unavailable",
|
|
183
|
+
};
|
|
184
|
+
onChange();
|
|
185
|
+
}
|
|
186
|
+
return false;
|
|
187
|
+
} finally {
|
|
188
|
+
busy = false;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
async function context(send) {
|
|
192
|
+
const raw = await send({
|
|
193
|
+
operation: "context",
|
|
194
|
+
...(memberFilter ? { memberId: memberFilter } : {}),
|
|
195
|
+
});
|
|
196
|
+
const result = projectThreadContext(raw, readScope().organizationId);
|
|
197
|
+
if (
|
|
198
|
+
(raw.memberId ?? null) !== memberFilter ||
|
|
199
|
+
typeof raw.threadsTruncated !== "boolean"
|
|
200
|
+
)
|
|
201
|
+
invalid();
|
|
202
|
+
value = {
|
|
203
|
+
...value,
|
|
204
|
+
...result,
|
|
205
|
+
memberId: memberFilter,
|
|
206
|
+
threadsTruncated: raw.threadsTruncated,
|
|
207
|
+
state: "ready",
|
|
208
|
+
};
|
|
209
|
+
if (selected && !raw.threadsTruncated && !value.threads.some(row => row.memberId === selected.memberId && row.threadKey === selected.threadKey)) {
|
|
210
|
+
selected = null;
|
|
211
|
+
value.detail = null;
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
async function clearStop(current, key) {
|
|
215
|
+
await fenceStore.clear(fenceKey(current, key));
|
|
216
|
+
pending.delete(key);
|
|
217
|
+
}
|
|
218
|
+
async function stop(send, current, key, version) {
|
|
219
|
+
const result = await send({
|
|
220
|
+
operation: "consent",
|
|
221
|
+
threadKey: key,
|
|
222
|
+
expectedVersion: version,
|
|
223
|
+
enabled: false,
|
|
224
|
+
});
|
|
225
|
+
if (
|
|
226
|
+
result.threadKey !== key ||
|
|
227
|
+
result.enabled !== false ||
|
|
228
|
+
!Number.isSafeInteger(result.version) ||
|
|
229
|
+
result.version <= version
|
|
230
|
+
)
|
|
231
|
+
invalid();
|
|
232
|
+
await clearStop(current, key);
|
|
233
|
+
value.own = value.own.map((row) =>
|
|
234
|
+
row.threadKey === key
|
|
235
|
+
? { ...row, enabled: false, version: result.version }
|
|
236
|
+
: row,
|
|
237
|
+
);
|
|
238
|
+
}
|
|
239
|
+
async function readSelected(send) {
|
|
240
|
+
if (!selected) return;
|
|
241
|
+
const selection = { ...selected };
|
|
242
|
+
const result = await send({ operation: "read", ...selection });
|
|
243
|
+
if (
|
|
244
|
+
selected?.memberId !== selection.memberId ||
|
|
245
|
+
selected?.threadKey !== selection.threadKey
|
|
246
|
+
)
|
|
247
|
+
return;
|
|
248
|
+
const summary = projectSummary(result);
|
|
249
|
+
if (
|
|
250
|
+
summary.memberId !== selection.memberId ||
|
|
251
|
+
summary.threadKey !== selection.threadKey ||
|
|
252
|
+
!Array.isArray(result.messages) ||
|
|
253
|
+
result.messages.length > 32 ||
|
|
254
|
+
typeof result.truncated !== "boolean" ||
|
|
255
|
+
typeof result.incomplete !== "boolean"
|
|
256
|
+
)
|
|
257
|
+
invalid();
|
|
258
|
+
let total = 0;
|
|
259
|
+
const messages = result.messages.map((row) => {
|
|
260
|
+
if (
|
|
261
|
+
!["user", "assistant"].includes(row?.role) ||
|
|
262
|
+
typeof row.text !== "string" ||
|
|
263
|
+
row.text.length > 4000
|
|
264
|
+
)
|
|
265
|
+
invalid();
|
|
266
|
+
total += row.text.length;
|
|
267
|
+
return { role: row.role, text: sanitizedText(row.text) };
|
|
268
|
+
});
|
|
269
|
+
if (total > 24000) invalid();
|
|
270
|
+
value.detail = {
|
|
271
|
+
...summary,
|
|
272
|
+
messages,
|
|
273
|
+
truncated: result.truncated,
|
|
274
|
+
incomplete: result.incomplete,
|
|
275
|
+
};
|
|
276
|
+
}
|
|
277
|
+
function sourceObservedAt(candidate) {
|
|
278
|
+
const observed = candidate.observation?.observedAt;
|
|
279
|
+
if (!Number.isFinite(observed) || observed <= 0 || observed > now()) invalid();
|
|
280
|
+
return new Date(observed).toISOString();
|
|
281
|
+
}
|
|
282
|
+
async function payload(candidate, signal) {
|
|
283
|
+
const result = await readContext(candidate, { signal });
|
|
284
|
+
if (!Array.isArray(result?.messages) || !result.messages.length) invalid();
|
|
285
|
+
// Existing provider readers exclude attachments/tool payloads and bound prose.
|
|
286
|
+
const messages = result.messages.map(({ role, text }) => ({ role, text }));
|
|
287
|
+
const observation = candidate.observation;
|
|
288
|
+
const running =
|
|
289
|
+
candidate.isRunning === true &&
|
|
290
|
+
observation?.source === "codex_app_server" &&
|
|
291
|
+
Number.isFinite(observation.observedAt) &&
|
|
292
|
+
now() - observation.observedAt >= 0 &&
|
|
293
|
+
now() - observation.observedAt <= 300000;
|
|
294
|
+
const parent = candidate.parentSession;
|
|
295
|
+
const parentThreadKey = parent && typeof parent.provider === "string" && typeof parent.session === "string" && parent.session
|
|
296
|
+
&& !(parent.provider === candidate.provider && parent.session === candidate.session)
|
|
297
|
+
? teamThreadKey(readScope(), parent) : null;
|
|
298
|
+
return {
|
|
299
|
+
parentThreadKey,
|
|
300
|
+
title: sanitizedText(candidate.title || "Work conversation").slice(
|
|
301
|
+
0,
|
|
302
|
+
240,
|
|
303
|
+
),
|
|
304
|
+
state: running ? "running" : "unknown",
|
|
305
|
+
observedAt: sourceObservedAt(candidate),
|
|
306
|
+
messages,
|
|
307
|
+
truncated: result.truncated === true,
|
|
308
|
+
incomplete: result.incomplete === true,
|
|
309
|
+
};
|
|
310
|
+
}
|
|
311
|
+
async function refresh({ publish = false, memberId = undefined } = {}) {
|
|
312
|
+
if (memberId !== undefined && memberFilter !== (memberId || null)) {
|
|
313
|
+
scope();
|
|
314
|
+
controller?.abort();
|
|
315
|
+
generation++;
|
|
316
|
+
memberFilter = memberId || null;
|
|
317
|
+
selected = null;
|
|
318
|
+
value.threads = [];
|
|
319
|
+
value.detail = null;
|
|
320
|
+
value.memberId = memberFilter;
|
|
321
|
+
onChange();
|
|
322
|
+
while (busy && !closed)
|
|
323
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
324
|
+
}
|
|
325
|
+
return run(async ({ current, send, valid, signal }) => {
|
|
326
|
+
lastPoll = now();
|
|
327
|
+
await context(send);
|
|
328
|
+
for (const item of value.own) {
|
|
329
|
+
const fence = await fenceStore.read(fenceKey(current, item.threadKey));
|
|
330
|
+
if (!valid()) return;
|
|
331
|
+
if (fence) {
|
|
332
|
+
// A lost OFF response (or intervening version) must reconcile against
|
|
333
|
+
// authenticated current state, rather than retry a stale CAS forever.
|
|
334
|
+
pending.set(item.threadKey, item.version);
|
|
335
|
+
if (item.enabled) await stop(send, current, item.threadKey, item.version);
|
|
336
|
+
else await clearStop(current, item.threadKey);
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
if (publish && !storageFailed)
|
|
340
|
+
for (const item of [...value.own]) {
|
|
341
|
+
// Server consent alone cannot re-arm publication after a process restart:
|
|
342
|
+
// an OFF may have failed both durable storage and the network.
|
|
343
|
+
if (!item.enabled || pending.has(item.threadKey) || publicationGrants.get(item.threadKey) !== item.version) continue;
|
|
344
|
+
const candidate = candidates.find(
|
|
345
|
+
(c) => teamThreadKey(current, c) === item.threadKey,
|
|
346
|
+
);
|
|
347
|
+
if (!candidate) continue;
|
|
348
|
+
let body;
|
|
349
|
+
try {
|
|
350
|
+
body = await payload(candidate, signal);
|
|
351
|
+
} catch {
|
|
352
|
+
continue;
|
|
353
|
+
}
|
|
354
|
+
if (!valid() || pending.has(item.threadKey)) return;
|
|
355
|
+
const receipt = await send({
|
|
356
|
+
operation: "publish",
|
|
357
|
+
threadKey: item.threadKey,
|
|
358
|
+
expectedVersion: item.version,
|
|
359
|
+
...body,
|
|
360
|
+
});
|
|
361
|
+
if (
|
|
362
|
+
receipt.threadKey !== item.threadKey ||
|
|
363
|
+
receipt.enabled !== true ||
|
|
364
|
+
receipt.version !== item.version
|
|
365
|
+
)
|
|
366
|
+
invalid();
|
|
367
|
+
}
|
|
368
|
+
await readSelected(send);
|
|
369
|
+
if (valid()) onChange();
|
|
370
|
+
});
|
|
371
|
+
}
|
|
372
|
+
return {
|
|
373
|
+
snapshot,
|
|
374
|
+
tick(sessions) {
|
|
375
|
+
candidates = sessions;
|
|
376
|
+
scope();
|
|
377
|
+
if (!busy && !closed && now() - lastPoll >= pollMs) {
|
|
378
|
+
lastPoll = now();
|
|
379
|
+
void refresh({ publish: true });
|
|
380
|
+
}
|
|
381
|
+
},
|
|
382
|
+
refresh,
|
|
383
|
+
async open({ memberId, threadKey }) {
|
|
384
|
+
scope();
|
|
385
|
+
selected = memberId && threadKey ? { memberId, threadKey } : null;
|
|
386
|
+
value.detail = null;
|
|
387
|
+
onChange();
|
|
388
|
+
if (!selected) return true;
|
|
389
|
+
// An in-flight context/publish must not replace this newer selection.
|
|
390
|
+
controller?.abort();
|
|
391
|
+
generation++;
|
|
392
|
+
while (busy && !closed)
|
|
393
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
394
|
+
return run(async ({ send, valid }) => {
|
|
395
|
+
await readSelected(send);
|
|
396
|
+
if (valid()) onChange();
|
|
397
|
+
});
|
|
398
|
+
},
|
|
399
|
+
async stopSharing(threadKey) {
|
|
400
|
+
scope();
|
|
401
|
+
if (!keyPattern.test(threadKey) || !value.own.some(row => row.threadKey === threadKey)) return false;
|
|
402
|
+
return setSharing(null, false, threadKey);
|
|
403
|
+
},
|
|
404
|
+
setSharing,
|
|
405
|
+
close() {
|
|
406
|
+
closed = true;
|
|
407
|
+
controller?.abort();
|
|
408
|
+
generation++;
|
|
409
|
+
publicationGrants.clear();
|
|
410
|
+
value = empty();
|
|
411
|
+
},
|
|
412
|
+
};
|
|
413
|
+
async function setSharing(candidate, enabled, existingKey) {
|
|
414
|
+
const current = scope(),
|
|
415
|
+
key = existingKey ?? teamThreadKey(current, candidate),
|
|
416
|
+
item = value.own.find((row) => row.threadKey === key),
|
|
417
|
+
version = item?.version ?? 0;
|
|
418
|
+
if (
|
|
419
|
+
typeof enabled !== "boolean" ||
|
|
420
|
+
(enabled && (!current.allowed || busy || pending.has(key)))
|
|
421
|
+
)
|
|
422
|
+
return false;
|
|
423
|
+
if (enabled && storageFailed) {
|
|
424
|
+
// An OFF acknowledgement confirms server removal, not writable local
|
|
425
|
+
// storage. Revalidate durability before accepting a later explicit ON.
|
|
426
|
+
if (item?.enabled !== false) return false;
|
|
427
|
+
// Probe a distinct opaque key so a concurrent OFF cannot lose its fence.
|
|
428
|
+
const probe = digest(`storage-probe:${current.fenceKey}`);
|
|
429
|
+
try {
|
|
430
|
+
await fenceStore.write(probe, 0);
|
|
431
|
+
await fenceStore.clear(probe);
|
|
432
|
+
} catch { return false; }
|
|
433
|
+
if (readScope().key !== current.key || pending.has(key)) return false;
|
|
434
|
+
storageFailed = false;
|
|
435
|
+
}
|
|
436
|
+
if (!enabled) {
|
|
437
|
+
publicationGrants.delete(key);
|
|
438
|
+
pending.set(key, version);
|
|
439
|
+
controller?.abort();
|
|
440
|
+
generation++;
|
|
441
|
+
onChange();
|
|
442
|
+
try {
|
|
443
|
+
await fenceStore.write(fenceKey(current, key), version);
|
|
444
|
+
} catch {
|
|
445
|
+
storageFailed = true;
|
|
446
|
+
value.state = "local_storage";
|
|
447
|
+
onChange();
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
while (busy && !closed)
|
|
451
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
452
|
+
if (readScope().key !== current.key || !readScope().authenticated) return false;
|
|
453
|
+
return run(async ({ current, send, valid, signal }) => {
|
|
454
|
+
if (!enabled) await stop(send, current, key, version);
|
|
455
|
+
else {
|
|
456
|
+
const fence = await fenceStore.read(fenceKey(current, key));
|
|
457
|
+
if (fence) invalid();
|
|
458
|
+
const body = await payload(candidate, signal);
|
|
459
|
+
const receipt = await send({
|
|
460
|
+
operation: "consent",
|
|
461
|
+
threadKey: key,
|
|
462
|
+
expectedVersion: version,
|
|
463
|
+
enabled: true,
|
|
464
|
+
...body,
|
|
465
|
+
});
|
|
466
|
+
if (
|
|
467
|
+
receipt.threadKey !== key ||
|
|
468
|
+
receipt.enabled !== true ||
|
|
469
|
+
receipt.version !== version + 1
|
|
470
|
+
)
|
|
471
|
+
invalid();
|
|
472
|
+
if (valid() && !pending.has(key)) publicationGrants.set(key, receipt.version);
|
|
473
|
+
}
|
|
474
|
+
if (current.allowed) await context(send);
|
|
475
|
+
if (valid()) onChange();
|
|
476
|
+
}, !enabled);
|
|
477
|
+
}
|
|
478
|
+
}
|