sprag-cli 3.69.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/NOTICE +5 -2
- package/README.ko.md +16 -0
- package/README.md +16 -0
- package/bin/cli.js +53 -4
- package/docs/CODEX.ko.md +3 -2
- package/docs/CODEX.md +5 -4
- package/docs/TEAM.ko.md +174 -0
- package/docs/TEAM.md +175 -0
- package/package.json +5 -1
- package/presets/agents.json +118 -0
- package/presets/doc2md/convert.py +1 -1
- package/presets/ratchet-rules.json +12 -2
- package/src/agents.js +3 -1
- package/src/cli-entry.js +40 -0
- package/src/codex/delegation.js +8 -5
- package/src/codex/hooks.js +25 -12
- package/src/codex/panel-setup.js +2 -1
- package/src/codex/panel.js +2 -1
- package/src/codex/route-scan.js +6 -4
- package/src/commands/brief.js +2 -0
- package/src/commands/codex-delegate.js +3 -3
- package/src/commands/codex.js +6 -5
- package/src/commands/harness.js +3 -4
- 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 +25 -10
- package/src/commands/team.js +334 -0
- package/src/commands/update-check.js +2 -2
- package/src/cost.js +6 -2
- package/src/cowork-sync.js +5 -4
- package/src/debug.js +3 -2
- package/src/delegation-agents.js +49 -49
- package/src/delegation-guard.js +3 -2
- 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-templates.js +10 -0
- package/src/harness.js +4 -14
- package/src/hook.cjs +4 -3
- package/src/model-rules.js +58 -29
- 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-caps.js +3 -3
- package/src/route-inject.js +24 -15
- package/src/route-scan.js +39 -15
- package/src/seed-rules.js +14 -16
- package/src/shared-model-rules.js +1 -1
- 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 +276 -0
- package/src/team-sync.js +625 -0
- package/src/update-check.js +3 -2
package/src/team-sync.js
ADDED
|
@@ -0,0 +1,625 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* team-sync — the opt-in team layer's client core.
|
|
3
|
+
*
|
|
4
|
+
* sprag is local-only by default. Nothing here runs unless team.json holds a
|
|
5
|
+
* member key, which only `sprag team join` (or SPRAG_TEAM_TOKEN auto-join)
|
|
6
|
+
* writes. Once joined, the CLI uploads DAILY AGGREGATES ONLY — spend, savings,
|
|
7
|
+
* delegation counts and model-family share per local calendar day — and pulls
|
|
8
|
+
* the team's approved rules. No prompts, paths or transcripts leave the machine.
|
|
9
|
+
*
|
|
10
|
+
* Files in the user data dir:
|
|
11
|
+
* team.json membership state (mode 0600; holds the member key)
|
|
12
|
+
* team-rules.json last pulled rules (organization rules first, then team
|
|
13
|
+
* rules; each row carries level 'org' or 'team')
|
|
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.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { envVar } from './env.js';
|
|
24
|
+
import { readFileSync, existsSync, rmSync, chmodSync } from 'node:fs';
|
|
25
|
+
import { execFileSync } from 'node:child_process';
|
|
26
|
+
import { join } from 'node:path';
|
|
27
|
+
import { userInfo, release as osRelease } from 'node:os';
|
|
28
|
+
import { createRequire } from 'node:module';
|
|
29
|
+
import { userDataDir } from './paths.js';
|
|
30
|
+
import { writeStateFile } from './state-file.js';
|
|
31
|
+
import { debug } from './debug.js';
|
|
32
|
+
import { loadLedger, modelFamily, roundUsd } from './savings-ledger.js';
|
|
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;
|
|
39
|
+
|
|
40
|
+
const require = createRequire(import.meta.url);
|
|
41
|
+
|
|
42
|
+
export const TEAM_API_URL = 'https://hiycfjwswtkixyoqkznm.supabase.co';
|
|
43
|
+
// Public anon key (role=anon). Access is gated by the RPCs, not by this value.
|
|
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;
|
|
48
|
+
export const TEAM_SYNC_INTERVAL_MS = 6 * 60 * 60 * 1000;
|
|
49
|
+
export const TEAM_STATE_VERSION = 1;
|
|
50
|
+
|
|
51
|
+
const TOKEN_RE = /^spt_[0-9a-f]{32}$/;
|
|
52
|
+
const MAX_ROWS = 400;
|
|
53
|
+
|
|
54
|
+
export function teamStatePath(dir = userDataDir()) { return join(dir, 'team.json'); }
|
|
55
|
+
export function teamRulesCachePath(dir = userDataDir()) { return join(dir, 'team-rules.json'); }
|
|
56
|
+
|
|
57
|
+
export function loadTeamState(dir = userDataDir()) {
|
|
58
|
+
try {
|
|
59
|
+
const s = JSON.parse(readFileSync(teamStatePath(dir), 'utf8'));
|
|
60
|
+
return s && typeof s === 'object' && !Array.isArray(s) ? s : {};
|
|
61
|
+
} catch {
|
|
62
|
+
return {};
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function saveTeamState(state, dir = userDataDir()) {
|
|
67
|
+
const file = teamStatePath(dir);
|
|
68
|
+
writeStateFile(file, JSON.stringify(state, null, 2) + '\n');
|
|
69
|
+
if (process.platform !== 'win32') {
|
|
70
|
+
try { chmodSync(file, 0o600); } catch (e) { debug('team-sync', e); }
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function teamSyncDisabled(env = process.env) { return envVar('NO_TEAM_SYNC', env) === '1'; }
|
|
75
|
+
export function isJoined(state) { return !!(state && state.memberKey && !state.left); }
|
|
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
|
+
*/
|
|
99
|
+
export function teamSyncDue(state, now = Date.now()) {
|
|
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
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export async function rpc(fn, params, {
|
|
152
|
+
fetch = globalThis.fetch, timeoutMs = 8000, url = teamApiUrl(), key = teamAnonKey(),
|
|
153
|
+
} = {}) {
|
|
154
|
+
const ctl = new AbortController();
|
|
155
|
+
const timer = setTimeout(() => ctl.abort(), timeoutMs);
|
|
156
|
+
try {
|
|
157
|
+
const res = await fetch(`${url}/rest/v1/rpc/${fn}`, {
|
|
158
|
+
method: 'POST',
|
|
159
|
+
headers: { apikey: key, Authorization: `Bearer ${key}`, 'Content-Type': 'application/json' },
|
|
160
|
+
body: JSON.stringify(params || {}),
|
|
161
|
+
signal: ctl.signal,
|
|
162
|
+
});
|
|
163
|
+
let body = null;
|
|
164
|
+
try { body = await res.json(); } catch { /* empty or non-JSON body */ }
|
|
165
|
+
if (!res.ok) {
|
|
166
|
+
const msg = (body && (body.message || body.hint)) || `HTTP ${res.status}`;
|
|
167
|
+
throw new Error(`${fn}: ${msg}`);
|
|
168
|
+
}
|
|
169
|
+
return body;
|
|
170
|
+
} catch (e) {
|
|
171
|
+
if (e && e.name === 'AbortError') throw new Error(`${fn}: timeout`);
|
|
172
|
+
throw e;
|
|
173
|
+
} finally {
|
|
174
|
+
clearTimeout(timer);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* One JSON request against the Supabase project, for the owner sign-in flow
|
|
180
|
+
* (GoTrue email code + the teams table). Same injectable fetch and 8s timeout
|
|
181
|
+
* as rpc(). Errors carry the server's own message. The access token only ever
|
|
182
|
+
* lives in memory: it is never written to disk or printed.
|
|
183
|
+
*/
|
|
184
|
+
async function apiJson(path, { body, accessToken, label, fetch = globalThis.fetch, timeoutMs = 8000, url = teamApiUrl(), key = teamAnonKey(), prefer } = {}) {
|
|
185
|
+
const ctl = new AbortController();
|
|
186
|
+
const timer = setTimeout(() => ctl.abort(), timeoutMs);
|
|
187
|
+
try {
|
|
188
|
+
const headers = { apikey: key, 'Content-Type': 'application/json' };
|
|
189
|
+
if (accessToken) headers.Authorization = `Bearer ${accessToken}`;
|
|
190
|
+
if (prefer) headers.Prefer = prefer;
|
|
191
|
+
const res = await fetch(`${url}${path}`, { method: 'POST', headers, body: JSON.stringify(body || {}), signal: ctl.signal });
|
|
192
|
+
let data = null;
|
|
193
|
+
try { data = await res.json(); } catch { /* empty or non-JSON body */ }
|
|
194
|
+
if (!res.ok) {
|
|
195
|
+
const msg = (data && (data.msg || data.error_description || data.message)) || `HTTP ${res.status}`;
|
|
196
|
+
throw new Error(`${label}: ${msg}`);
|
|
197
|
+
}
|
|
198
|
+
return data;
|
|
199
|
+
} catch (e) {
|
|
200
|
+
if (e && e.name === 'AbortError') throw new Error(`${label}: timeout`);
|
|
201
|
+
throw e;
|
|
202
|
+
} finally {
|
|
203
|
+
clearTimeout(timer);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/** Email a 6-digit sign-in code (valid 15 minutes). Creates the user if new. */
|
|
208
|
+
export async function requestSignInCode({ email, fetch } = {}) {
|
|
209
|
+
await apiJson('/auth/v1/otp', { body: { email, create_user: true }, label: 'sign-in', fetch });
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** Exchange the emailed code for a session. Returns { accessToken, userId }. */
|
|
213
|
+
export async function verifySignInCode({ email, code, fetch } = {}) {
|
|
214
|
+
const r = await apiJson('/auth/v1/verify', { body: { type: 'email', email, token: code }, label: 'verify', fetch });
|
|
215
|
+
if (!r || !r.access_token || !r.user || !r.user.id) throw new Error('verify: no session returned');
|
|
216
|
+
return { accessToken: r.access_token, userId: r.user.id };
|
|
217
|
+
}
|
|
218
|
+
|
|
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,
|
|
229
|
+
});
|
|
230
|
+
const row = Array.isArray(r) ? r[0] : r;
|
|
231
|
+
if (!row || !row.invite_token) throw new Error('create team: no invite token returned');
|
|
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
|
+
};
|
|
236
|
+
}
|
|
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
|
+
|
|
249
|
+
/** Local calendar day, same key as stats.js. */
|
|
250
|
+
function dayKey(ms) {
|
|
251
|
+
const d = new Date(ms);
|
|
252
|
+
const pad = (n) => String(n).padStart(2, '0');
|
|
253
|
+
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
const round4 = (n) => Math.round(n * 10000) / 10000;
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* Per-day rows, oldest first, only days with activity inside the window.
|
|
260
|
+
* saved_usd honours the `sprag saved reset` marks (events before a mark are
|
|
261
|
+
* skipped), the same way the ledger totals do. delegated_errors is always null:
|
|
262
|
+
* the ledger carries no error flag.
|
|
263
|
+
*/
|
|
264
|
+
export async function aggregateDaily({ now = Date.now(), dir = userDataDir(), days = 30, sessions = null } = {}) {
|
|
265
|
+
const { readResetMarks, isBeforeReset } = require('./saved-reset.cjs');
|
|
266
|
+
const doc2md = require('./doc2md-ledger.cjs');
|
|
267
|
+
const cutoff = now - days * 24 * 3600 * 1000;
|
|
268
|
+
const byDay = new Map();
|
|
269
|
+
const get = (day) => {
|
|
270
|
+
if (!byDay.has(day)) byDay.set(day, { spent: 0, saved: 0, runs: 0, fam: new Map() });
|
|
271
|
+
return byDay.get(day);
|
|
272
|
+
};
|
|
273
|
+
let marks = {};
|
|
274
|
+
try { marks = readResetMarks(dir) || {}; } catch (e) { debug('team-sync', e); }
|
|
275
|
+
|
|
276
|
+
try {
|
|
277
|
+
for (const e of Object.values(loadLedger().events)) {
|
|
278
|
+
if (!Number.isFinite(e.ts) || e.ts < cutoff || e.ts > now) continue;
|
|
279
|
+
if (isBeforeReset(e.ts, marks.routing)) continue;
|
|
280
|
+
const d = get(dayKey(e.ts));
|
|
281
|
+
const usd = Number(e.usd);
|
|
282
|
+
if (Number.isFinite(usd)) d.saved += usd;
|
|
283
|
+
d.runs += 1;
|
|
284
|
+
const fam = modelFamily(e.to);
|
|
285
|
+
d.fam.set(fam, (d.fam.get(fam) || 0) + 1);
|
|
286
|
+
}
|
|
287
|
+
} catch (e) { debug('team-sync', e); }
|
|
288
|
+
|
|
289
|
+
try {
|
|
290
|
+
for (const e of Object.values(doc2md.loadLedger(dir).events)) {
|
|
291
|
+
if (!Number.isFinite(e.ts) || e.ts < cutoff || e.ts > now) continue;
|
|
292
|
+
if (isBeforeReset(e.ts, marks.doc2md)) continue;
|
|
293
|
+
get(dayKey(e.ts)).saved += Number(e.usd) || 0;
|
|
294
|
+
}
|
|
295
|
+
} catch (e) { debug('team-sync', e); }
|
|
296
|
+
|
|
297
|
+
try {
|
|
298
|
+
let list = sessions;
|
|
299
|
+
if (!list) {
|
|
300
|
+
const { parseAllSessions } = await import('./parser.js');
|
|
301
|
+
list = await parseAllSessions({ days });
|
|
302
|
+
}
|
|
303
|
+
for (const s of list || []) {
|
|
304
|
+
if (!s || !s.startTime || !s.totals) continue;
|
|
305
|
+
const t = new Date(s.startTime).getTime();
|
|
306
|
+
if (!Number.isFinite(t) || t < cutoff || t > now) continue;
|
|
307
|
+
let cost = 0;
|
|
308
|
+
for (const part of pricedParts(s)) {
|
|
309
|
+
try {
|
|
310
|
+
const c = sessionCost(part.totals, part.model);
|
|
311
|
+
if (c) cost += c.actual;
|
|
312
|
+
} catch (e) { debug('team-sync', e); }
|
|
313
|
+
}
|
|
314
|
+
get(dayKey(t)).spent += cost;
|
|
315
|
+
}
|
|
316
|
+
} catch (e) { debug('team-sync', e); }
|
|
317
|
+
|
|
318
|
+
return [...byDay.entries()]
|
|
319
|
+
.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
|
|
320
|
+
.filter(([, d]) => d.spent || d.saved || d.runs)
|
|
321
|
+
.map(([day, d]) => ({
|
|
322
|
+
day,
|
|
323
|
+
spent_usd: round4(d.spent),
|
|
324
|
+
saved_usd: round4(d.saved),
|
|
325
|
+
delegated_runs: d.runs,
|
|
326
|
+
delegated_errors: null,
|
|
327
|
+
family_share: Object.fromEntries(
|
|
328
|
+
[...d.fam.entries()].map(([f, n]) => [f, Math.round((n / d.runs) * 1000) / 1000]),
|
|
329
|
+
),
|
|
330
|
+
}));
|
|
331
|
+
}
|
|
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
|
+
|
|
386
|
+
export async function joinTeam({
|
|
387
|
+
token, displayName, agent = 'claude', autoJoined = false, fetch, dir = userDataDir(), version, now = Date.now(), env = process.env,
|
|
388
|
+
} = {}) {
|
|
389
|
+
if (!TOKEN_RE.test(String(token || ''))) throw new Error('invalid invite token (expected spt_ + 32 hex characters)');
|
|
390
|
+
const name = String(displayName || '').trim() || 'member';
|
|
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
|
+
}
|
|
400
|
+
if (!r || !r.member_key) throw new Error('team_join: no member key returned');
|
|
401
|
+
const state = {
|
|
402
|
+
version: TEAM_STATE_VERSION,
|
|
403
|
+
teamId: r.team_id,
|
|
404
|
+
teamName: r.team_name,
|
|
405
|
+
memberId: r.member_id,
|
|
406
|
+
memberKey: r.member_key,
|
|
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,
|
|
412
|
+
promptCapture: !!r.prompt_capture,
|
|
413
|
+
displayName: name,
|
|
414
|
+
agent,
|
|
415
|
+
joinedAt: now,
|
|
416
|
+
autoJoined: !!autoJoined,
|
|
417
|
+
autoJoinNoticePending: !!autoJoined,
|
|
418
|
+
lastSyncAt: null,
|
|
419
|
+
lastSyncRows: 0,
|
|
420
|
+
};
|
|
421
|
+
saveTeamState(state, dir);
|
|
422
|
+
return reportDeviceInfo({ state, dir, fetch, env });
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
export async function leaveTeam({ fetch, dir = userDataDir(), now = Date.now() } = {}) {
|
|
426
|
+
const prev = loadTeamState(dir);
|
|
427
|
+
let lastError;
|
|
428
|
+
if (prev.memberKey) {
|
|
429
|
+
try { await rpc('team_leave', { member_key: prev.memberKey }, { fetch }); }
|
|
430
|
+
catch (e) { debug('team-sync', e); lastError = e.message; }
|
|
431
|
+
}
|
|
432
|
+
const next = { version: TEAM_STATE_VERSION, left: true, leftAt: now, teamName: prev.teamName, displayName: prev.displayName };
|
|
433
|
+
if (lastError) next.lastError = lastError;
|
|
434
|
+
saveTeamState(next, dir);
|
|
435
|
+
try { rmSync(teamRulesCachePath(dir), { force: true }); } catch (e) { debug('team-sync', e); }
|
|
436
|
+
return next;
|
|
437
|
+
}
|
|
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
|
+
|
|
479
|
+
export async function syncTeam({
|
|
480
|
+
now = Date.now(), dir = userDataDir(), fetch, force = false, env = process.env, version, sessions, promptsOnly = false, rulesOnly = false,
|
|
481
|
+
} = {}) {
|
|
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
|
+
}
|
|
497
|
+
if (state.left) return { skipped: 'left' };
|
|
498
|
+
if (!isJoined(state)) return { skipped: 'not-joined' };
|
|
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 });
|
|
544
|
+
try {
|
|
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;
|
|
552
|
+
delete next.lastError;
|
|
553
|
+
saveTeamState(next, dir);
|
|
554
|
+
refreshTeamRatchetFiles({ dir });
|
|
555
|
+
const prompts = await syncPrompts({ state: next, dir, fetch, now });
|
|
556
|
+
return { uploaded: rows.length, rules: rules.length, ...(prompts !== null ? { prompts } : {}) };
|
|
557
|
+
} catch (e) {
|
|
558
|
+
debug('team-sync', e);
|
|
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); }
|
|
564
|
+
return { error: e.message };
|
|
565
|
+
}
|
|
566
|
+
}
|
|
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
|
+
|
|
600
|
+
export async function autoJoin({ env = process.env, fetch, dir = userDataDir(), version, now = Date.now() } = {}) {
|
|
601
|
+
const token = env.SPRAG_TEAM_TOKEN;
|
|
602
|
+
if (!token || !TOKEN_RE.test(token)) return null;
|
|
603
|
+
const state = loadTeamState(dir);
|
|
604
|
+
// A state with `left: true` never auto-rejoins: leaving must beat the
|
|
605
|
+
// environment, or `sprag team leave` would be undone by the next session.
|
|
606
|
+
if (isJoined(state) || state.left) return null;
|
|
607
|
+
let name = 'member';
|
|
608
|
+
try { name = userInfo().username || 'member'; } catch { /* no passwd entry */ }
|
|
609
|
+
try {
|
|
610
|
+
return await joinTeam({ token, displayName: name, autoJoined: true, fetch, dir, version, now, env });
|
|
611
|
+
} catch (e) {
|
|
612
|
+
// SeatLimitError (code SEAT_LIMIT) lands here too: auto-join stays silent and retries next session.
|
|
613
|
+
debug('team-sync', e);
|
|
614
|
+
return null;
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
export function autoJoinNotice(state, lang = 'en') {
|
|
619
|
+
if (!state || !state.autoJoinNoticePending) return null;
|
|
620
|
+
state.autoJoinNoticePending = false;
|
|
621
|
+
const name = state.teamName || '';
|
|
622
|
+
return lang === 'ko'
|
|
623
|
+
? `Sprag: 이 PC는 SPRAG_TEAM_TOKEN 으로 팀 "${name}" 에 자동 참여됐습니다. 보내는 것은 하루 단위 집계(지출·절감·위임 횟수)뿐입니다. \`sprag team status --show-payload\` 로 내용을 확인하고 \`sprag team leave\` 로 멈출 수 있습니다.`
|
|
624
|
+
: `Sprag: this machine was enrolled in team "${name}" through SPRAG_TEAM_TOKEN. It sends daily aggregates only (spend, savings, delegation counts). See what is sent with \`sprag team status --show-payload\`; stop with \`sprag team leave\`.`;
|
|
625
|
+
}
|
package/src/update-check.js
CHANGED
|
@@ -29,10 +29,11 @@
|
|
|
29
29
|
* once-a-day pass as the version check, and its absence never blocks the
|
|
30
30
|
* notice — a version number alone is still worth saying.
|
|
31
31
|
*
|
|
32
|
-
* Opt out with
|
|
32
|
+
* Opt out with SPRAG_NO_UPDATE_CHECK=1 or NO_UPDATE_NOTIFIER (the de-facto
|
|
33
33
|
* standard env var — anyone who set it for other CLIs meant us too).
|
|
34
34
|
*/
|
|
35
35
|
|
|
36
|
+
import { envVar } from './env.js';
|
|
36
37
|
import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs';
|
|
37
38
|
import { spawn } from 'node:child_process';
|
|
38
39
|
import { join, dirname, sep } from 'node:path';
|
|
@@ -63,7 +64,7 @@ export function updateStatePath() {
|
|
|
63
64
|
}
|
|
64
65
|
|
|
65
66
|
export function updateCheckDisabled() {
|
|
66
|
-
return
|
|
67
|
+
return envVar('NO_UPDATE_CHECK') === '1' || !!process.env.NO_UPDATE_NOTIFIER;
|
|
67
68
|
}
|
|
68
69
|
|
|
69
70
|
export function readUpdateState() {
|