sprag-cli 3.70.0 → 3.71.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.ko.md +7 -1
- package/README.md +7 -1
- package/bin/cli.js +47 -5
- package/docs/CODEX.ko.md +2 -2
- package/docs/CODEX.md +4 -4
- package/docs/TEAM.ko.md +74 -19
- package/docs/TEAM.md +75 -19
- package/package.json +2 -1
- package/presets/agents.json +118 -0
- package/presets/doc2md/convert.py +1 -1
- package/presets/ratchet-rules.json +12 -2
- package/src/cli-entry.js +40 -0
- package/src/codex/hooks.js +11 -10
- package/src/codex/panel-setup.js +2 -1
- package/src/codex/panel.js +2 -1
- package/src/commands/brief.js +2 -0
- package/src/commands/codex.js +3 -2
- package/src/commands/harness.js +2 -3
- package/src/commands/install.js +19 -18
- package/src/commands/presets.js +158 -0
- package/src/commands/profile-map.js +2 -2
- package/src/commands/route-scan.js +9 -7
- package/src/commands/team.js +82 -26
- package/src/commands/update-check.js +2 -2
- package/src/debug.js +3 -2
- package/src/delegation-agents.js +48 -60
- package/src/doc2md.cjs +4 -3
- package/src/env.cjs +7 -0
- package/src/env.js +19 -0
- package/src/first-run-note.js +3 -2
- package/src/harness.js +4 -14
- package/src/hook.cjs +4 -3
- package/src/preset-sign.js +106 -0
- package/src/preset-sync.js +133 -0
- package/src/presets-source.js +209 -0
- package/src/prompt.js +2 -1
- package/src/route-scan.js +14 -1
- package/src/seed-rules.js +14 -16
- package/src/star-note.js +3 -2
- package/src/statusline-guard.js +144 -0
- package/src/stdin-payload.js +33 -4
- package/src/team-prompts.js +353 -0
- package/src/team-ratchet.js +116 -16
- package/src/team-sync.js +336 -37
- package/src/update-check.js +3 -2
package/src/team-ratchet.js
CHANGED
|
@@ -10,6 +10,8 @@
|
|
|
10
10
|
* Codex <CODEX_HOME>/ratchet-team.md
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
|
+
import { envVar } from './env.js';
|
|
14
|
+
import { CLI_ENTRY, detachedChildEnv, isDetachedChild } from './cli-entry.js';
|
|
13
15
|
import * as childProcess from 'node:child_process';
|
|
14
16
|
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
15
17
|
import { homedir } from 'node:os';
|
|
@@ -30,6 +32,7 @@ export { TEAM_RATCHET_IMPORT_RE, teamRatchetImportLine };
|
|
|
30
32
|
|
|
31
33
|
export const TEAM_RATCHET_MARKER = '<!-- sprag:team-ratchet -->';
|
|
32
34
|
const SYNC_INTERVAL_MS = 6 * 60 * 60 * 1000;
|
|
35
|
+
const IN_FLIGHT_MS = 2 * 60 * 1000;
|
|
33
36
|
const TOKEN_RE = /^spt_[0-9a-f]{32}$/;
|
|
34
37
|
|
|
35
38
|
export const teamStatePathFor = (dir = userDataDir()) => join(dir, 'team.json');
|
|
@@ -56,8 +59,50 @@ export function isJoined(state) {
|
|
|
56
59
|
|
|
57
60
|
export function teamSyncDue(state, now = Date.now()) {
|
|
58
61
|
if (!isJoined(state)) return false;
|
|
62
|
+
// Same rule as teamSyncDue in team-sync.js (a test pins the two together).
|
|
63
|
+
const attempt = Number(state.lastSyncAttemptAt);
|
|
64
|
+
if (Number.isFinite(attempt) && attempt > 0 && now - attempt >= 0 && now - attempt < IN_FLIGHT_MS) return false;
|
|
65
|
+
const failures = Number(state.syncFailures) || 0;
|
|
66
|
+
if (failures > 0) {
|
|
67
|
+
const delay = Math.min(15 * 60 * 1000 * 2 ** (failures - 1), 60 * 60 * 1000);
|
|
68
|
+
return !Number.isFinite(attempt) || attempt <= 0 || now - attempt >= delay;
|
|
69
|
+
}
|
|
59
70
|
const at = Number(state.lastSyncAt);
|
|
60
|
-
return !Number.isFinite(at) || at <= 0 || now - at
|
|
71
|
+
return !Number.isFinite(at) || at <= 0 || now - at >= SYNC_INTERVAL_MS;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export const PROMPT_SYNC_INTERVAL_MS = 30 * 60 * 1000;
|
|
75
|
+
export const RULES_PULL_INTERVAL_MS = 60 * 60 * 1000;
|
|
76
|
+
const RULES_RETRY_MS = 15 * 60 * 1000;
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* The rules-only pull (one small RPC, no ledger parsing) is due an hour after
|
|
80
|
+
* the last time rules arrived, by either kind of sync. It stays out of the way
|
|
81
|
+
* of the full sync: not while that one is in flight or backing off after a
|
|
82
|
+
* failure (its retry pulls the rules too), and not within 15 minutes of its own
|
|
83
|
+
* last attempt, which doubles as its in-flight guard and failure backoff.
|
|
84
|
+
*/
|
|
85
|
+
export function rulesPullDue(state, now = Date.now(), env = process.env) {
|
|
86
|
+
if (!isJoined(state) || state.pausedReported || teamSyncDisabled(env)) return false;
|
|
87
|
+
if ((Number(state.syncFailures) || 0) > 0) return false;
|
|
88
|
+
const attempt = Number(state.lastSyncAttemptAt);
|
|
89
|
+
if (Number.isFinite(attempt) && attempt > 0 && now - attempt >= 0 && now - attempt < IN_FLIGHT_MS) return false;
|
|
90
|
+
const tried = Number(state.lastRulesAttemptAt);
|
|
91
|
+
if (Number.isFinite(tried) && tried > 0 && now - tried >= 0 && now - tried < RULES_RETRY_MS) return false;
|
|
92
|
+
const last = Math.max(Number(state.lastRulesAt) || 0, Number(state.lastSyncAt) || 0);
|
|
93
|
+
return last <= 0 || now - last >= RULES_PULL_INTERVAL_MS;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Stat-free gate for the prompt uploader: joined, capture on (as last seen by a
|
|
98
|
+
* sync), uploads not switched off or reported paused, and the last prompt run is
|
|
99
|
+
* 30 minutes old. Reads nothing but the already-loaded team.json.
|
|
100
|
+
*/
|
|
101
|
+
export function promptSyncDue(state, now = Date.now(), env = process.env) {
|
|
102
|
+
if (!isJoined(state) || state.promptCapture !== true || state.pausedReported) return false;
|
|
103
|
+
if (teamSyncDisabled(env)) return false;
|
|
104
|
+
const at = Number(state.lastPromptSyncAt);
|
|
105
|
+
return !Number.isFinite(at) || at <= 0 || now - at >= PROMPT_SYNC_INTERVAL_MS;
|
|
61
106
|
}
|
|
62
107
|
|
|
63
108
|
export function autoJoinPending(state, env = process.env) {
|
|
@@ -65,7 +110,7 @@ export function autoJoinPending(state, env = process.env) {
|
|
|
65
110
|
}
|
|
66
111
|
|
|
67
112
|
export function teamSyncDisabled(env = process.env) {
|
|
68
|
-
return env
|
|
113
|
+
return envVar('NO_TEAM_SYNC', env) === '1';
|
|
69
114
|
}
|
|
70
115
|
|
|
71
116
|
export function teamRatchetPath({ agent = 'claude' } = {}) {
|
|
@@ -78,19 +123,34 @@ export function renderTeamRatchet(cache, { lang = 'en', agent = 'claude' } = {})
|
|
|
78
123
|
if (!cache || !Array.isArray(cache.rules)) return null;
|
|
79
124
|
const removed = new Set(Array.isArray(cache.removed) ? cache.removed : []);
|
|
80
125
|
const live = cache.rules.filter((r) => r && typeof r.text === 'string' && r.text.trim() && !removed.has(r.id));
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
126
|
+
if (!live.length) return null;
|
|
127
|
+
// Rows from servers older than the org layer carry no level: they are team rules.
|
|
128
|
+
const org = live.filter((r) => r.level === 'org');
|
|
129
|
+
const team = live.filter((r) => r.level !== 'org');
|
|
130
|
+
const isModel = (r) => r.scope === 'model';
|
|
84
131
|
const ko = lang === 'ko' && agent !== 'codex';
|
|
85
132
|
const name = cache.teamName || '';
|
|
133
|
+
const orgName = cache.orgName || '';
|
|
134
|
+
const scopeLine = ko
|
|
135
|
+
? (orgName ? `조직 "${orgName}" 과 팀 "${name}" 에서 승인한 룰입니다.` : `팀 "${name}" 이 승인한 룰입니다.`)
|
|
136
|
+
: (orgName ? `Rules approved for organization "${orgName}" and team "${name}".` : `Rules approved by team "${name}".`);
|
|
86
137
|
const head = ko
|
|
87
|
-
? `# 팀 래칫 룰 (sprag 관리)\n\n
|
|
88
|
-
: `# Team Ratchet Rules (managed by sprag)\n\
|
|
138
|
+
? `# 팀 래칫 룰 (sprag 관리)\n\n${scopeLine} sprag 가 세션 시작 때 통째로 다시 쓰므로 직접 고치지 마십시오. 목록: \`sprag team rules\``
|
|
139
|
+
: `# Team Ratchet Rules (managed by sprag)\n\n${scopeLine} sprag rewrites this file at session start, so do not edit it by hand. List: \`sprag team rules\``;
|
|
140
|
+
const H = ko
|
|
141
|
+
? { org: '조직 룰', orgModel: '조직 모델 룰', team: '팀 룰', teamModel: '팀 모델 룰' }
|
|
142
|
+
: { org: 'Organization rules', orgModel: 'Organization model rules', team: 'Team rules', teamModel: 'Team model rules' };
|
|
89
143
|
const line = (r) => `- ${r.text.trim().replace(/\s*\n\s*/g, ' ')}`;
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
144
|
+
const section = (rows, title, modelTitle) => {
|
|
145
|
+
if (!rows.length) return '';
|
|
146
|
+
const plain = rows.filter((r) => !isModel(r));
|
|
147
|
+
const model = rows.filter(isModel);
|
|
148
|
+
let s = `\n## ${title}\n`;
|
|
149
|
+
if (plain.length) s += `\n${plain.map(line).join('\n')}\n`;
|
|
150
|
+
if (model.length) s += `\n### ${modelTitle}\n\n${model.map(line).join('\n')}\n`;
|
|
151
|
+
return s;
|
|
152
|
+
};
|
|
153
|
+
return `${TEAM_RATCHET_MARKER}\n${head}\n${section(org, H.org, H.orgModel)}${section(team, H.team, H.teamModel)}`;
|
|
94
154
|
}
|
|
95
155
|
|
|
96
156
|
function writeIfChanged(file, text) {
|
|
@@ -149,13 +209,26 @@ export function ensureTeamImport() {
|
|
|
149
209
|
|
|
150
210
|
export function spawnTeamSyncIfDue({
|
|
151
211
|
state = readTeamState(), now = Date.now(), env = process.env,
|
|
152
|
-
spawn = childProcess.spawn,
|
|
212
|
+
spawn = childProcess.spawn, cliPath = CLI_ENTRY, execPath = process.execPath,
|
|
153
213
|
} = {}) {
|
|
154
|
-
if (
|
|
155
|
-
|
|
214
|
+
if (isDetachedChild(env)) return false;
|
|
215
|
+
// Switched off: spawn once more only to report the pause (team-sync.js).
|
|
216
|
+
// Switched back on after a reported pause: spawn now to report the resume.
|
|
217
|
+
// Otherwise the full sync when it is due, else the hourly rules-only pull.
|
|
218
|
+
let flags;
|
|
219
|
+
if (teamSyncDisabled(env)) {
|
|
220
|
+
if (!isJoined(state) || state.pausedReported) return false;
|
|
221
|
+
flags = ['--background'];
|
|
222
|
+
} else if (autoJoinPending(state, env) || teamSyncDue(state, now) || (isJoined(state) && state.pausedReported)) {
|
|
223
|
+
flags = ['--background'];
|
|
224
|
+
} else if (rulesPullDue(state, now, env)) {
|
|
225
|
+
flags = ['--background', '--rules-only'];
|
|
226
|
+
} else {
|
|
227
|
+
return false;
|
|
228
|
+
}
|
|
156
229
|
try {
|
|
157
|
-
spawn(execPath, [
|
|
158
|
-
{ detached: true, stdio: 'ignore', windowsHide: true }).unref();
|
|
230
|
+
spawn(execPath, [cliPath, 'team', 'sync', ...flags],
|
|
231
|
+
{ detached: true, stdio: 'ignore', windowsHide: true, env: detachedChildEnv(env) }).unref();
|
|
159
232
|
return true;
|
|
160
233
|
} catch (e) {
|
|
161
234
|
debug('team-ratchet:spawn', e);
|
|
@@ -163,6 +236,33 @@ export function spawnTeamSyncIfDue({
|
|
|
163
236
|
}
|
|
164
237
|
}
|
|
165
238
|
|
|
239
|
+
/**
|
|
240
|
+
* Called from the per-prompt hooks: one small team.json read, no network and no
|
|
241
|
+
* parsing here. When the ledger/rules sync is due (6 hours after the last success,
|
|
242
|
+
* or its failure backoff elapsed), start the detached `team sync --background`
|
|
243
|
+
* child; else, when only the prompt upload is due, start it in prompts-only
|
|
244
|
+
* mode; else, when only the hourly rules pull is due, start it in rules-only
|
|
245
|
+
* mode. Prints nothing.
|
|
246
|
+
*/
|
|
247
|
+
export function spawnTeamSyncOnPrompt({
|
|
248
|
+
state = readTeamState(), now = Date.now(), env = process.env,
|
|
249
|
+
spawn = childProcess.spawn, cliPath = CLI_ENTRY, execPath = process.execPath,
|
|
250
|
+
} = {}) {
|
|
251
|
+
if (isDetachedChild(env) || teamSyncDisabled(env) || !isJoined(state)) return false;
|
|
252
|
+
let flags;
|
|
253
|
+
if (teamSyncDue(state, now)) flags = ['--background'];
|
|
254
|
+
else if (promptSyncDue(state, now, env)) flags = ['--background', '--prompts-only'];
|
|
255
|
+
else if (rulesPullDue(state, now, env)) flags = ['--background', '--rules-only'];
|
|
256
|
+
else return false;
|
|
257
|
+
try {
|
|
258
|
+
spawn(execPath, [cliPath, 'team', 'sync', ...flags], { detached: true, stdio: 'ignore', windowsHide: true, env: detachedChildEnv(env) }).unref();
|
|
259
|
+
return true;
|
|
260
|
+
} catch (e) {
|
|
261
|
+
debug('team-ratchet:spawn-prompt-hook', e);
|
|
262
|
+
return false;
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
166
266
|
export function autoJoinNoticeBlock(dir = userDataDir(), lang = userLanguage()) {
|
|
167
267
|
const file = teamStatePathFor(dir);
|
|
168
268
|
const state = readJson(file);
|
package/src/team-sync.js
CHANGED
|
@@ -9,26 +9,42 @@
|
|
|
9
9
|
*
|
|
10
10
|
* Files in the user data dir:
|
|
11
11
|
* team.json membership state (mode 0600; holds the member key)
|
|
12
|
-
* team-rules.json last pulled
|
|
12
|
+
* team-rules.json last pulled rules (organization rules first, then team
|
|
13
|
+
* rules; each row carries level 'org' or 'team')
|
|
13
14
|
*
|
|
14
|
-
*
|
|
15
|
+
* Rules arrive with every full sync (6 hours) and with the rules-only pull
|
|
16
|
+
* (`team sync --rules-only`, at most hourly). Either kind rewrites the ratchet
|
|
17
|
+
* files right away, so a new session picks the rules up without waiting for
|
|
18
|
+
* the next session-start render.
|
|
19
|
+
*
|
|
20
|
+
* Opt out of all network traffic with SPRAG_NO_TEAM_SYNC=1.
|
|
15
21
|
*/
|
|
16
22
|
|
|
23
|
+
import { envVar } from './env.js';
|
|
17
24
|
import { readFileSync, existsSync, rmSync, chmodSync } from 'node:fs';
|
|
25
|
+
import { execFileSync } from 'node:child_process';
|
|
18
26
|
import { join } from 'node:path';
|
|
19
|
-
import { userInfo } from 'node:os';
|
|
27
|
+
import { userInfo, release as osRelease } from 'node:os';
|
|
20
28
|
import { createRequire } from 'node:module';
|
|
21
29
|
import { userDataDir } from './paths.js';
|
|
22
30
|
import { writeStateFile } from './state-file.js';
|
|
23
31
|
import { debug } from './debug.js';
|
|
24
32
|
import { loadLedger, modelFamily, roundUsd } from './savings-ledger.js';
|
|
25
33
|
import { sessionCost, pricedParts } from './claude-price.js';
|
|
34
|
+
import { uploadPrompts, dropOffsets, promptOffsetsExist } from './team-prompts.js';
|
|
35
|
+
import { rulesPullDue, syncTeamRatchet, ensureTeamImport } from './team-ratchet.js';
|
|
36
|
+
import { codexUserDir } from './agent.js';
|
|
37
|
+
|
|
38
|
+
export const PROMPT_SYNC_INTERVAL_MS = 30 * 60 * 1000;
|
|
26
39
|
|
|
27
40
|
const require = createRequire(import.meta.url);
|
|
28
41
|
|
|
29
42
|
export const TEAM_API_URL = 'https://hiycfjwswtkixyoqkznm.supabase.co';
|
|
30
43
|
// Public anon key (role=anon). Access is gated by the RPCs, not by this value.
|
|
31
44
|
export const TEAM_ANON_KEY = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6ImhpeWNmandzd3RraXh5b3Frem5tIiwicm9sZSI6ImFub24iLCJpYXQiOjE3OTIyNTc4MzYsImV4cCI6MjEwNzgzMzgzNn0.gyYcvGD70CEX64aGV-dsmZgmmOBdvhJlzIpx9Qo2l3w';
|
|
45
|
+
// Local testing only: SPRAG_TEAM_API_URL + SPRAG_TEAM_ANON_KEY point the client at a local stack.
|
|
46
|
+
export const teamApiUrl = (env = process.env) => envVar('TEAM_API_URL', env) || TEAM_API_URL;
|
|
47
|
+
export const teamAnonKey = (env = process.env) => envVar('TEAM_ANON_KEY', env) || TEAM_ANON_KEY;
|
|
32
48
|
export const TEAM_SYNC_INTERVAL_MS = 6 * 60 * 60 * 1000;
|
|
33
49
|
export const TEAM_STATE_VERSION = 1;
|
|
34
50
|
|
|
@@ -55,15 +71,85 @@ export function saveTeamState(state, dir = userDataDir()) {
|
|
|
55
71
|
}
|
|
56
72
|
}
|
|
57
73
|
|
|
58
|
-
export function teamSyncDisabled(env = process.env) { return env
|
|
74
|
+
export function teamSyncDisabled(env = process.env) { return envVar('NO_TEAM_SYNC', env) === '1'; }
|
|
59
75
|
export function isJoined(state) { return !!(state && state.memberKey && !state.left); }
|
|
60
76
|
|
|
77
|
+
/** Prompt uploads run on their own 30-minute timer, only while capture is on. */
|
|
78
|
+
export function promptSyncDue(state, now = Date.now()) {
|
|
79
|
+
return isJoined(state) && state.promptCapture === true && !state.pausedReported
|
|
80
|
+
&& (!state.lastPromptSyncAt || now - state.lastPromptSyncAt >= PROMPT_SYNC_INTERVAL_MS);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export const LEDGER_RETRY_BASE_MS = 15 * 60 * 1000;
|
|
84
|
+
export const LEDGER_RETRY_MAX_MS = 60 * 60 * 1000;
|
|
85
|
+
const IN_FLIGHT_MS = 2 * 60 * 1000;
|
|
86
|
+
export const LEDGER_WINDOW_DAYS = 30;
|
|
87
|
+
|
|
88
|
+
/** Retry delay after n consecutive failed ledger syncs: 15, 30, 60 minutes, then 60. */
|
|
89
|
+
export function ledgerRetryDelayMs(failures) {
|
|
90
|
+
const n = Math.max(1, Math.floor(Number(failures) || 1));
|
|
91
|
+
return Math.min(LEDGER_RETRY_BASE_MS * 2 ** (n - 1), LEDGER_RETRY_MAX_MS);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* The ledger/rules sync is due every 6 hours after the last SUCCESS, or, after a
|
|
96
|
+
* failure, when the backoff since the last attempt has elapsed. An attempt that
|
|
97
|
+
* began less than 2 minutes ago counts as in flight, so two hooks do not both spawn.
|
|
98
|
+
*/
|
|
61
99
|
export function teamSyncDue(state, now = Date.now()) {
|
|
62
|
-
|
|
100
|
+
if (!isJoined(state)) return false;
|
|
101
|
+
const attempt = Number(state.lastSyncAttemptAt);
|
|
102
|
+
if (Number.isFinite(attempt) && attempt > 0 && now - attempt >= 0 && now - attempt < IN_FLIGHT_MS) return false;
|
|
103
|
+
const failures = Number(state.syncFailures) || 0;
|
|
104
|
+
if (failures > 0) return !Number.isFinite(attempt) || attempt <= 0 || now - attempt >= ledgerRetryDelayMs(failures);
|
|
105
|
+
return !state.lastSyncAt || now - state.lastSyncAt >= TEAM_SYNC_INTERVAL_MS;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Which local days a ledger sync uploads: from the day before the watermark (that
|
|
110
|
+
* day was still open when it was uploaded) through today, at most 30 days. With
|
|
111
|
+
* no watermark, the last 30 days.
|
|
112
|
+
*/
|
|
113
|
+
export function ledgerWindow(state, now = Date.now()) {
|
|
114
|
+
const today = new Date(now);
|
|
115
|
+
const earliest = new Date(today.getFullYear(), today.getMonth(), today.getDate() - (LEDGER_WINDOW_DAYS - 1));
|
|
116
|
+
let start = earliest;
|
|
117
|
+
const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(String(state?.ledgerSyncedThrough || ''));
|
|
118
|
+
if (m) {
|
|
119
|
+
const from = new Date(Number(m[1]), Number(m[2]) - 1, Number(m[3]) - 1);
|
|
120
|
+
if (from > earliest) start = from;
|
|
121
|
+
}
|
|
122
|
+
const startKey = dayKey(start.getTime());
|
|
123
|
+
// aggregateDaily cuts by time, so ask for a little more and filter by day below
|
|
124
|
+
const days = Math.ceil((now - start.getTime()) / 86400000) + 1;
|
|
125
|
+
return { startKey, days };
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* The preset bundle version this machine runs (0 for the bundled presets), so
|
|
130
|
+
* the /ops console can show how far a published version has rolled out. A
|
|
131
|
+
* server without the preset_version argument (before 20261018001400_presets)
|
|
132
|
+
* answers "Could not find the function"; the upload is then repeated without it.
|
|
133
|
+
*/
|
|
134
|
+
async function ledgerUpsert({ memberKey, rows, version, dir, fetch }) {
|
|
135
|
+
const params = { member_key: memberKey, rows, sprag_version: version || '' };
|
|
136
|
+
let presetVersion = null;
|
|
137
|
+
try {
|
|
138
|
+
const { presetSource } = await import('./presets-source.js');
|
|
139
|
+
const s = presetSource({ dir });
|
|
140
|
+
presetVersion = s.kind === 'remote' ? s.version : 0;
|
|
141
|
+
} catch (e) { debug('team-sync:preset-version', e); }
|
|
142
|
+
if (presetVersion == null) return rpc('ledger_upsert', params, { fetch });
|
|
143
|
+
try {
|
|
144
|
+
return await rpc('ledger_upsert', { ...params, preset_version: presetVersion }, { fetch });
|
|
145
|
+
} catch (e) {
|
|
146
|
+
if (!/preset_version|Could not find the function|PGRST202/i.test(String(e && e.message))) throw e;
|
|
147
|
+
return rpc('ledger_upsert', params, { fetch });
|
|
148
|
+
}
|
|
63
149
|
}
|
|
64
150
|
|
|
65
151
|
export async function rpc(fn, params, {
|
|
66
|
-
fetch = globalThis.fetch, timeoutMs = 8000, url =
|
|
152
|
+
fetch = globalThis.fetch, timeoutMs = 8000, url = teamApiUrl(), key = teamAnonKey(),
|
|
67
153
|
} = {}) {
|
|
68
154
|
const ctl = new AbortController();
|
|
69
155
|
const timer = setTimeout(() => ctl.abort(), timeoutMs);
|
|
@@ -95,7 +181,7 @@ export async function rpc(fn, params, {
|
|
|
95
181
|
* as rpc(). Errors carry the server's own message. The access token only ever
|
|
96
182
|
* lives in memory: it is never written to disk or printed.
|
|
97
183
|
*/
|
|
98
|
-
async function apiJson(path, { body, accessToken, label, fetch = globalThis.fetch, timeoutMs = 8000, url =
|
|
184
|
+
async function apiJson(path, { body, accessToken, label, fetch = globalThis.fetch, timeoutMs = 8000, url = teamApiUrl(), key = teamAnonKey(), prefer } = {}) {
|
|
99
185
|
const ctl = new AbortController();
|
|
100
186
|
const timer = setTimeout(() => ctl.abort(), timeoutMs);
|
|
101
187
|
try {
|
|
@@ -130,16 +216,36 @@ export async function verifySignInCode({ email, code, fetch } = {}) {
|
|
|
130
216
|
return { accessToken: r.access_token, userId: r.user.id };
|
|
131
217
|
}
|
|
132
218
|
|
|
133
|
-
/**
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
219
|
+
/**
|
|
220
|
+
* Create a team through the team_create RPC as the signed-in user. A null orgId
|
|
221
|
+
* makes the server open a new organization named after the team; otherwise the
|
|
222
|
+
* caller must be an owner or admin of that organization. userId is accepted for
|
|
223
|
+
* call-site compatibility; the server derives the owner from the session.
|
|
224
|
+
*/
|
|
225
|
+
export async function createTeam({ name, accessToken, userId, orgId = null, fetch } = {}) {
|
|
226
|
+
void userId;
|
|
227
|
+
const r = await apiJson('/rest/v1/rpc/team_create', {
|
|
228
|
+
body: { p_name: name, p_org_id: orgId || null }, accessToken, label: 'create team', fetch,
|
|
137
229
|
});
|
|
138
230
|
const row = Array.isArray(r) ? r[0] : r;
|
|
139
231
|
if (!row || !row.invite_token) throw new Error('create team: no invite token returned');
|
|
140
|
-
return {
|
|
232
|
+
return {
|
|
233
|
+
id: row.id, name: row.name, inviteToken: row.invite_token, plan: row.plan,
|
|
234
|
+
orgId: row.org_id || null, orgName: row.org_name || null,
|
|
235
|
+
};
|
|
141
236
|
}
|
|
142
237
|
|
|
238
|
+
/** Typed error for the server's 'seat limit reached' refusal (code SEAT_LIMIT). */
|
|
239
|
+
export class SeatLimitError extends Error {
|
|
240
|
+
constructor(message) {
|
|
241
|
+
super(message);
|
|
242
|
+
this.name = 'SeatLimitError';
|
|
243
|
+
this.code = 'SEAT_LIMIT';
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
export const isSeatLimitError = (e) => !!e && (e.code === 'SEAT_LIMIT' || /seat limit reached/i.test(String(e.message || '')));
|
|
248
|
+
|
|
143
249
|
/** Local calendar day, same key as stats.js. */
|
|
144
250
|
function dayKey(ms) {
|
|
145
251
|
const d = new Date(ms);
|
|
@@ -224,14 +330,73 @@ export async function aggregateDaily({ now = Date.now(), dir = userDataDir(), da
|
|
|
224
330
|
}));
|
|
225
331
|
}
|
|
226
332
|
|
|
333
|
+
// Device info: OS name, OS version and CPU architecture. Nothing that names the
|
|
334
|
+
// machine (no hostname, username or path) is read or sent.
|
|
335
|
+
const DARWIN_TO_MACOS = { 20: '11', 21: '12', 22: '13', 23: '14', 24: '15', 25: '26' };
|
|
336
|
+
|
|
337
|
+
export function deviceInfo({
|
|
338
|
+
platform = process.platform, arch = process.arch, release = osRelease(),
|
|
339
|
+
exec = (cmd, args) => execFileSync(cmd, args, { timeout: 2000, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }),
|
|
340
|
+
readText = (f) => readFileSync(f, 'utf8'),
|
|
341
|
+
} = {}) {
|
|
342
|
+
let os = String(platform);
|
|
343
|
+
let osVersion = String(release || '');
|
|
344
|
+
try {
|
|
345
|
+
if (platform === 'darwin') {
|
|
346
|
+
os = 'macOS';
|
|
347
|
+
let v = '';
|
|
348
|
+
try { v = String(exec('sw_vers', ['-productVersion'])).trim(); } catch (e) { debug('team-sync', e); }
|
|
349
|
+
osVersion = v || DARWIN_TO_MACOS[parseInt(release, 10)] || osVersion;
|
|
350
|
+
} else if (platform === 'win32') {
|
|
351
|
+
os = 'Windows';
|
|
352
|
+
const build = parseInt(String(release).split('.')[2], 10);
|
|
353
|
+
osVersion = build >= 22000 ? '11' : '10';
|
|
354
|
+
} else if (platform === 'linux') {
|
|
355
|
+
os = 'Linux';
|
|
356
|
+
try {
|
|
357
|
+
const text = readText('/etc/os-release');
|
|
358
|
+
const get = (k) => (text.match(new RegExp(`^${k}=(.*)$`, 'm')) || [])[1]?.trim().replace(/^"|"$/g, '');
|
|
359
|
+
// NAME + VERSION_ID ("Ubuntu", "24.04"): the dashboard prints them
|
|
360
|
+
// side by side, and PRETTY_NAME already carries the version.
|
|
361
|
+
const name = get('NAME') || get('PRETTY_NAME');
|
|
362
|
+
if (name) { os = name; osVersion = get('VERSION_ID') || osVersion; }
|
|
363
|
+
} catch (e) { debug('team-sync', e); }
|
|
364
|
+
}
|
|
365
|
+
} catch (e) { debug('team-sync', e); }
|
|
366
|
+
return { os: os.slice(0, 40), os_version: osVersion.slice(0, 40), arch: String(arch).slice(0, 40) };
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
// Reports device info once after a join and again only when it changes. Fails
|
|
370
|
+
// soft: the next sync retries. Never runs while uploads are switched off.
|
|
371
|
+
export async function reportDeviceInfo({ state, dir = userDataDir(), fetch, env = process.env } = {}) {
|
|
372
|
+
if (teamSyncDisabled(env) || !isJoined(state)) return state;
|
|
373
|
+
const info = deviceInfo();
|
|
374
|
+
if (state.deviceInfo && JSON.stringify(state.deviceInfo) === JSON.stringify(info)) return state;
|
|
375
|
+
try {
|
|
376
|
+
await rpc('member_set_info', { member_key: state.memberKey, ...info }, { fetch });
|
|
377
|
+
const next = { ...state, deviceInfo: info };
|
|
378
|
+
saveTeamState(next, dir);
|
|
379
|
+
return next;
|
|
380
|
+
} catch (e) {
|
|
381
|
+
debug('team-sync', e);
|
|
382
|
+
return state;
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
|
|
227
386
|
export async function joinTeam({
|
|
228
|
-
token, displayName, agent = 'claude', autoJoined = false, fetch, dir = userDataDir(), version, now = Date.now(),
|
|
387
|
+
token, displayName, agent = 'claude', autoJoined = false, fetch, dir = userDataDir(), version, now = Date.now(), env = process.env,
|
|
229
388
|
} = {}) {
|
|
230
389
|
if (!TOKEN_RE.test(String(token || ''))) throw new Error('invalid invite token (expected spt_ + 32 hex characters)');
|
|
231
390
|
const name = String(displayName || '').trim() || 'member';
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
391
|
+
let r;
|
|
392
|
+
try {
|
|
393
|
+
r = await rpc('team_join', {
|
|
394
|
+
invite_token: token, display_name: name, agent, sprag_version: version || '', auto_joined: !!autoJoined,
|
|
395
|
+
}, { fetch });
|
|
396
|
+
} catch (e) {
|
|
397
|
+
if (isSeatLimitError(e)) throw new SeatLimitError(e.message);
|
|
398
|
+
throw e;
|
|
399
|
+
}
|
|
235
400
|
if (!r || !r.member_key) throw new Error('team_join: no member key returned');
|
|
236
401
|
const state = {
|
|
237
402
|
version: TEAM_STATE_VERSION,
|
|
@@ -240,6 +405,10 @@ export async function joinTeam({
|
|
|
240
405
|
memberId: r.member_id,
|
|
241
406
|
memberKey: r.member_key,
|
|
242
407
|
plan: r.plan,
|
|
408
|
+
orgId: r.org_id || null,
|
|
409
|
+
orgName: r.org_name || null,
|
|
410
|
+
seatsUsed: Number.isFinite(r.seats_used) ? r.seats_used : null,
|
|
411
|
+
seatLimit: Number.isFinite(r.seat_limit) ? r.seat_limit : null,
|
|
243
412
|
promptCapture: !!r.prompt_capture,
|
|
244
413
|
displayName: name,
|
|
245
414
|
agent,
|
|
@@ -250,7 +419,7 @@ export async function joinTeam({
|
|
|
250
419
|
lastSyncRows: 0,
|
|
251
420
|
};
|
|
252
421
|
saveTeamState(state, dir);
|
|
253
|
-
return state;
|
|
422
|
+
return reportDeviceInfo({ state, dir, fetch, env });
|
|
254
423
|
}
|
|
255
424
|
|
|
256
425
|
export async function leaveTeam({ fetch, dir = userDataDir(), now = Date.now() } = {}) {
|
|
@@ -267,38 +436,167 @@ export async function leaveTeam({ fetch, dir = userDataDir(), now = Date.now() }
|
|
|
267
436
|
return next;
|
|
268
437
|
}
|
|
269
438
|
|
|
439
|
+
/**
|
|
440
|
+
* Pull the approved rules and write team-rules.json. Rows are kept as the
|
|
441
|
+
* server sends them, `level` included. Returns the RPC body and the rule rows.
|
|
442
|
+
*/
|
|
443
|
+
async function pullRules({ memberKey, dir, fetch, now }) {
|
|
444
|
+
const r = (await rpc('team_rules_get', { member_key: memberKey }, { fetch })) || {};
|
|
445
|
+
const rules = Array.isArray(r.rules) ? r.rules : [];
|
|
446
|
+
writeStateFile(teamRulesCachePath(dir), JSON.stringify({
|
|
447
|
+
version: 1, fetchedAt: now, teamId: r.team_id, teamName: r.team_name, orgId: r.org_id || null, orgName: r.org_name || null,
|
|
448
|
+
plan: r.plan, promptCapture: !!r.prompt_capture, rules, removed: Array.isArray(r.removed) ? r.removed : [],
|
|
449
|
+
}, null, 2) + '\n');
|
|
450
|
+
return { r, rules };
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
/** State fields a rules pull refreshes. */
|
|
454
|
+
function withRulesMeta(state, r, now) {
|
|
455
|
+
const next = { ...state, lastRulesAt: now };
|
|
456
|
+
if (r.team_name) next.teamName = r.team_name;
|
|
457
|
+
if (r.plan) next.plan = r.plan;
|
|
458
|
+
if (r.org_name) next.orgName = r.org_name;
|
|
459
|
+
if (r.prompt_capture !== undefined) next.promptCapture = !!r.prompt_capture;
|
|
460
|
+
delete next.lastRulesError;
|
|
461
|
+
return next;
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
/**
|
|
465
|
+
* Re-render the ratchet files from the fresh cache, for Claude Code and, when
|
|
466
|
+
* its home exists, Codex, and keep the CLAUDE.md import in step. Fails soft:
|
|
467
|
+
* the session-start hook renders them again anyway.
|
|
468
|
+
*/
|
|
469
|
+
export function refreshTeamRatchetFiles({ dir = userDataDir() } = {}) {
|
|
470
|
+
const out = {};
|
|
471
|
+
for (const agent of ['claude', 'codex']) {
|
|
472
|
+
if (agent === 'codex' && !existsSync(codexUserDir())) continue;
|
|
473
|
+
try { out[agent] = syncTeamRatchet({ agent, dir }); } catch (e) { debug('team-sync:ratchet', e); }
|
|
474
|
+
}
|
|
475
|
+
try { ensureTeamImport(); } catch (e) { debug('team-sync:import', e); }
|
|
476
|
+
return out;
|
|
477
|
+
}
|
|
478
|
+
|
|
270
479
|
export async function syncTeam({
|
|
271
|
-
now = Date.now(), dir = userDataDir(), fetch, force = false, env = process.env, version, sessions,
|
|
480
|
+
now = Date.now(), dir = userDataDir(), fetch, force = false, env = process.env, version, sessions, promptsOnly = false, rulesOnly = false,
|
|
272
481
|
} = {}) {
|
|
273
|
-
|
|
274
|
-
|
|
482
|
+
let state = loadTeamState(dir);
|
|
483
|
+
// The switch stops every usage upload. A joined machine still tells the
|
|
484
|
+
// server once that it paused (no usage data), so the organization sees a
|
|
485
|
+
// paused seat instead of a machine that went quiet.
|
|
486
|
+
if (teamSyncDisabled(env)) {
|
|
487
|
+
if (!isJoined(state) || state.pausedReported) return { skipped: 'disabled' };
|
|
488
|
+
try {
|
|
489
|
+
await rpc('member_set_sync', { member_key: state.memberKey, paused: true }, { fetch });
|
|
490
|
+
saveTeamState({ ...state, pausedReported: true }, dir);
|
|
491
|
+
return { skipped: 'disabled', reported: 'paused' };
|
|
492
|
+
} catch (e) {
|
|
493
|
+
debug('team-sync', e);
|
|
494
|
+
return { skipped: 'disabled', error: e.message };
|
|
495
|
+
}
|
|
496
|
+
}
|
|
275
497
|
if (state.left) return { skipped: 'left' };
|
|
276
498
|
if (!isJoined(state)) return { skipped: 'not-joined' };
|
|
277
|
-
|
|
499
|
+
let resumed = false;
|
|
500
|
+
if (state.pausedReported) {
|
|
501
|
+
try {
|
|
502
|
+
await rpc('member_set_sync', { member_key: state.memberKey, paused: false }, { fetch });
|
|
503
|
+
state = { ...state };
|
|
504
|
+
delete state.pausedReported;
|
|
505
|
+
saveTeamState(state, dir);
|
|
506
|
+
force = true;
|
|
507
|
+
resumed = true;
|
|
508
|
+
} catch (e) {
|
|
509
|
+
debug('team-sync', e);
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
// Rules only: one small RPC, no ledger and no prompts, and lastSyncAt stays
|
|
513
|
+
// put. A resume falls through to the full sync instead.
|
|
514
|
+
if (rulesOnly && !resumed) {
|
|
515
|
+
if (!force && !rulesPullDue(state, now, env)) return { skipped: 'not-due' };
|
|
516
|
+
state = { ...state, lastRulesAttemptAt: now };
|
|
517
|
+
try { saveTeamState(state, dir); } catch (e) { debug('team-sync', e); }
|
|
518
|
+
try {
|
|
519
|
+
const { r, rules } = await pullRules({ memberKey: state.memberKey, dir, fetch, now });
|
|
520
|
+
saveTeamState(withRulesMeta(loadTeamState(dir), r, now), dir);
|
|
521
|
+
refreshTeamRatchetFiles({ dir });
|
|
522
|
+
return { uploaded: 0, rules: rules.length, rulesOnly: true };
|
|
523
|
+
} catch (e) {
|
|
524
|
+
debug('team-sync', e);
|
|
525
|
+
try { saveTeamState({ ...loadTeamState(dir), lastRulesError: e.message }, dir); } catch (e2) { debug('team-sync', e2); }
|
|
526
|
+
return { error: e.message };
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
// Prompts have their own 30-minute timer and are cheap (bytes past the stored
|
|
530
|
+
// offsets); the ledger re-parses 30 days of sessions and keeps its 6 hours.
|
|
531
|
+
if (promptsOnly || (!force && !teamSyncDue(state, now))) {
|
|
532
|
+
if (!force && !promptSyncDue(state, now)) return { skipped: 'not-due' };
|
|
533
|
+
if (!state.promptCapture) return { skipped: 'not-due' };
|
|
534
|
+
// claim the slot first so a second hook in the same minute finds nothing due
|
|
535
|
+
state = { ...state, lastPromptSyncAt: now };
|
|
536
|
+
saveTeamState(state, dir);
|
|
537
|
+
const prompts = await syncPrompts({ state, dir, fetch, now });
|
|
538
|
+
return { uploaded: 0, rules: 0, prompts: prompts ?? 0, promptsOnly: true };
|
|
539
|
+
}
|
|
540
|
+
// Claim the attempt first: a second hook within two minutes sees it in flight.
|
|
541
|
+
state = { ...state, lastSyncAttemptAt: now };
|
|
542
|
+
try { saveTeamState(state, dir); } catch (e) { debug('team-sync', e); }
|
|
543
|
+
state = await reportDeviceInfo({ state, dir, fetch, env });
|
|
278
544
|
try {
|
|
279
|
-
const
|
|
280
|
-
await
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
}, null, 2) + '\n');
|
|
287
|
-
const next = { ...state, lastSyncAt: now, lastSyncRows: rows.length };
|
|
288
|
-
if (r.team_name) next.teamName = r.team_name;
|
|
289
|
-
if (r.plan) next.plan = r.plan;
|
|
290
|
-
if (r.prompt_capture !== undefined) next.promptCapture = !!r.prompt_capture;
|
|
545
|
+
const win = ledgerWindow(state, now);
|
|
546
|
+
const rows = (await aggregateDaily({ now, dir, sessions, days: win.days }))
|
|
547
|
+
.filter((r) => r.day >= win.startKey).slice(-MAX_ROWS);
|
|
548
|
+
await ledgerUpsert({ memberKey: state.memberKey, rows, version, dir, fetch });
|
|
549
|
+
const { r, rules } = await pullRules({ memberKey: state.memberKey, dir, fetch, now });
|
|
550
|
+
const next = withRulesMeta({ ...state, lastSyncAt: now, lastSyncAttemptAt: now, lastSyncRows: rows.length, ledgerSyncedThrough: dayKey(now) }, r, now);
|
|
551
|
+
delete next.syncFailures;
|
|
291
552
|
delete next.lastError;
|
|
292
553
|
saveTeamState(next, dir);
|
|
293
|
-
|
|
554
|
+
refreshTeamRatchetFiles({ dir });
|
|
555
|
+
const prompts = await syncPrompts({ state: next, dir, fetch, now });
|
|
556
|
+
return { uploaded: rows.length, rules: rules.length, ...(prompts !== null ? { prompts } : {}) };
|
|
294
557
|
} catch (e) {
|
|
295
558
|
debug('team-sync', e);
|
|
296
|
-
//
|
|
297
|
-
|
|
559
|
+
// lastSyncAt stays the last SUCCESS and the watermark does not move, so the
|
|
560
|
+
// next success re-sends every missed day. Retry after 15, 30, 60 minutes.
|
|
561
|
+
try {
|
|
562
|
+
saveTeamState({ ...state, lastSyncAttemptAt: now, syncFailures: (Number(state.syncFailures) || 0) + 1, lastError: e.message }, dir);
|
|
563
|
+
} catch (e2) { debug('team-sync', e2); }
|
|
298
564
|
return { error: e.message };
|
|
299
565
|
}
|
|
300
566
|
}
|
|
301
567
|
|
|
568
|
+
/**
|
|
569
|
+
* Prompt upload step of a sync. Capture off: forget the offsets so a later
|
|
570
|
+
* switch-on starts at the end of every file. Capture on: upload what is new.
|
|
571
|
+
* Fails soft; a problem here never marks the whole sync as failed.
|
|
572
|
+
*/
|
|
573
|
+
async function syncPrompts({ state, dir, fetch, now = Date.now() }) {
|
|
574
|
+
try {
|
|
575
|
+
if (!state.promptCapture) {
|
|
576
|
+
if (promptOffsetsExist(dir)) dropOffsets(dir);
|
|
577
|
+
return null;
|
|
578
|
+
}
|
|
579
|
+
const put = (params) => rpc('prompt_chunk_put', { member_key: state.memberKey, ...params }, { fetch, timeoutMs: 30000 });
|
|
580
|
+
const r = await uploadPrompts({ put, dir });
|
|
581
|
+
const fresh = loadTeamState(dir);
|
|
582
|
+
if (r.off) {
|
|
583
|
+
dropOffsets(dir);
|
|
584
|
+
saveTeamState({ ...fresh, promptCapture: false }, dir);
|
|
585
|
+
return null;
|
|
586
|
+
}
|
|
587
|
+
const patch = { lastPromptSyncAt: now };
|
|
588
|
+
if (r.uploaded > 0) {
|
|
589
|
+
Object.assign(patch, { lastPromptUploadAt: Date.now(), lastPromptCount: r.uploaded,
|
|
590
|
+
promptsUploadedTotal: (fresh.promptsUploadedTotal || 0) + r.uploaded });
|
|
591
|
+
}
|
|
592
|
+
saveTeamState({ ...fresh, ...patch }, dir);
|
|
593
|
+
return r.uploaded;
|
|
594
|
+
} catch (e) {
|
|
595
|
+
debug('team-sync', e);
|
|
596
|
+
return null;
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
|
|
302
600
|
export async function autoJoin({ env = process.env, fetch, dir = userDataDir(), version, now = Date.now() } = {}) {
|
|
303
601
|
const token = env.SPRAG_TEAM_TOKEN;
|
|
304
602
|
if (!token || !TOKEN_RE.test(token)) return null;
|
|
@@ -309,8 +607,9 @@ export async function autoJoin({ env = process.env, fetch, dir = userDataDir(),
|
|
|
309
607
|
let name = 'member';
|
|
310
608
|
try { name = userInfo().username || 'member'; } catch { /* no passwd entry */ }
|
|
311
609
|
try {
|
|
312
|
-
return await joinTeam({ token, displayName: name, autoJoined: true, fetch, dir, version, now });
|
|
610
|
+
return await joinTeam({ token, displayName: name, autoJoined: true, fetch, dir, version, now, env });
|
|
313
611
|
} catch (e) {
|
|
612
|
+
// SeatLimitError (code SEAT_LIMIT) lands here too: auto-join stays silent and retries next session.
|
|
314
613
|
debug('team-sync', e);
|
|
315
614
|
return null;
|
|
316
615
|
}
|