@diffin/cli 0.0.0-stage → 0.1.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.
@@ -0,0 +1,443 @@
1
+ // `diffin capture` — the new work of every agent session on this machine (`--this`: only
2
+ // the session running it), sent through the same run protocol a backfill uses. The agent decides WHEN (a work
3
+ // boundary); nothing on this machine decides WHAT the work was. Extraction, placement
4
+ // and grouping happen on the server with the same models and rules a backfill gets,
5
+ // so a captured session and a backfilled one come out the same shape and dedup by
6
+ // turn ref. The predecessor's skill had the user's own agent extract atoms locally:
7
+ // a second extractor with a different view, and atoms with no refs that a later
8
+ // backfill could not recognise as its own.
9
+ //
10
+ // There is no confirmation: consent was given at install and is scoped by folder
11
+ // (`--exclude` at backfill, a `.diffin-ignore` file for capture) and paused by
12
+ // `diffin pause`. The agent runs this the way it runs a commit.
13
+ import { readdirSync, statSync, existsSync } from 'node:fs';
14
+ import { join } from 'node:path';
15
+ import { SERVER, fetchRetry, reportFail, pinRun, runAuth } from './net.mjs';
16
+ import { tokenOrDie } from './creds.mjs';
17
+ import { claudeCode } from './sources/claude-code.mjs';
18
+ import { adapter, allAdapters } from './sources/index.mjs';
19
+ import { streamCutScrub } from './stream.mjs';
20
+ import { scrub, posixPath } from './scrub.mjs';
21
+ import { clock, dim, fail, marks, say, stepDone, when } from './ui.mjs';
22
+ import { markSent, nextCaptureAt, setNextCapture, pausedSince, pause as pauseState, resume as resumeState, canWriteState, deviceId } from './state.mjs';
23
+ import { putFile } from './backfill.mjs';
24
+ import { machineHash } from './machine.mjs';
25
+ import { detectAgent, agentById } from './agents.mjs';
26
+ import { ensureInit } from './init.mjs';
27
+ import { ALL_LOOKBACK_MS, allowFolders, allowedFolders, allowedSince, excludeFolders, excludedFolders, heldBy, ignoredBy, includeFolder, topFolders, withoutIgnored } from './ignore.mjs';
28
+ import { homedir } from 'node:os';
29
+
30
+ const ROOT = join(process.env.DIFFIN_HOME ?? homedir(), '.claude', 'projects');
31
+
32
+ /**
33
+ * The session being written right now. Claude Code says which one it is
34
+ * (CLAUDE_CODE_SESSION_ID); when that is absent or points at nothing, the newest
35
+ * transcript in this folder's project dir is the fallback.
36
+ */
37
+ export function currentSession(cwd = process.cwd(), env = process.env) {
38
+ // how the agent names a folder's project dir: separators (and a Windows drive's colon) become '-', and newer
39
+ // builds turn every character that is not a letter or digit into one. The first spelling that exists is it.
40
+ const spellings = [...new Set([cwd.replace(/\//g, '-'), cwd.replace(/[\\/:]/g, '-'), cwd.replace(/[^A-Za-z0-9]/g, '-')])];
41
+ const projectDir = spellings.find((d) => existsSync(join(ROOT, d))) ?? spellings[0];
42
+ const dir = join(ROOT, projectDir);
43
+ const named = env.CLAUDE_CODE_SESSION_ID;
44
+ if (named && /^[A-Za-z0-9_-]+$/.test(named)) {
45
+ if (existsSync(join(dir, named + '.jsonl'))) {
46
+ return { sid: named, path: join(dir, named + '.jsonl'), mtime: statSync(join(dir, named + '.jsonl')).mtimeMs, projectDir, how: 'env' };
47
+ }
48
+ // The agent names the session; the folder is a guess. A session keeps writing to
49
+ // the project dir it started in even after the person cd's elsewhere or renames
50
+ // the folder — measured: a session begun in a folder that was later deleted could
51
+ // not capture from anywhere. The id is authoritative, so look for it everywhere.
52
+ if (existsSync(ROOT)) {
53
+ for (const d of readdirSync(ROOT)) {
54
+ const f = join(ROOT, d, named + '.jsonl');
55
+ if (existsSync(f)) return { sid: named, path: f, mtime: statSync(f).mtimeMs, projectDir: d, how: 'env-elsewhere' };
56
+ }
57
+ }
58
+ }
59
+ if (!existsSync(dir)) return null;
60
+ let best = null;
61
+ for (const f of readdirSync(dir)) {
62
+ if (!f.endsWith('.jsonl') || f.startsWith('._')) continue;
63
+ const m = statSync(join(dir, f)).mtimeMs;
64
+ if (!best || m > best.mtime) best = { sid: f.replace(/\.jsonl$/, ''), path: join(dir, f), mtime: m, projectDir, how: 'newest' };
65
+ }
66
+ return best;
67
+ }
68
+
69
+ /** what would be sent: the in-window user turns, scrubbed, one line each */
70
+ // every turn, not the first dozen: this printout is what the person approves
71
+ // The preview applies the same resend rules the server's scan applies, so the count
72
+ // and the lines the person approves are what will be recorded: an unanswered turn
73
+ // re-sent (one-character fix, or the same text with more appended) is one turn, and a
74
+ // turn already in the tail before the window (the [PRIOR CONTEXT] lead-in) is not new.
75
+ // The same rule SCAN applies on the server (`pipeline/src/stages/scan.ts`), exported from both so a test can hold
76
+ // them equal: the preview's count is a promise about what the ledger will record.
77
+ export function nearDuplicate(a, b) {
78
+ const n = Math.max(a.length, b.length);
79
+ if (n < 16) return false;
80
+ if (a === b) return true;
81
+ if (a.length >= 16 && b.startsWith(a)) return true;
82
+ if (Math.abs(a.length - b.length) > n * 0.1) return false;
83
+ let p = 0; while (p < a.length && p < b.length && a[p] === b[p]) p++;
84
+ let q = 0; while (q < a.length - p && q < b.length - p && a[a.length - 1 - q] === b[b.length - 1 - q]) q++;
85
+ return (p + q) / n >= 0.9;
86
+ }
87
+ const norm = (t) => String(t ?? '').replace(/\[이미지\]/g, '').replace(/\s+/g, ' ').trim();
88
+
89
+ export async function preview(rows, cutOpts, max = Infinity) {
90
+ const lines = [];
91
+ const priorUsers = [];
92
+ let turns = 0, first = null, last = null;
93
+ let pending = null; // the last user turn: { at, key } and whether the assistant answered it
94
+ for await (const line of streamCutScrub(rows, cutOpts)) {
95
+ let r; try { r = JSON.parse(line); } catch { continue; }
96
+ if (r.type === 'assistant') {
97
+ const blocks = Array.isArray(r.message?.content) ? r.message.content : [];
98
+ const ctx = blocks.find((b) => b?.type === 'text' && String(b.text ?? '').startsWith('[PRIOR CONTEXT'));
99
+ if (ctx) {
100
+ let cur = null;
101
+ const flush = () => { if (cur) { const k = norm(cur); if (k.length > 16) priorUsers.push(k); } cur = null; };
102
+ for (const l of String(ctx.text).split('\n').slice(1)) { if (l.startsWith('user: ')) { flush(); cur = l.slice(6); } else if (l.startsWith('ai: ')) flush(); else if (cur !== null) cur += '\n' + l; }
103
+ flush();
104
+ } else if (pending && blocks.some((b) => (b?.type === 'text' && String(b.text ?? '').trim()) || b?.type === 'tool_use')) pending.answered = true;
105
+ continue;
106
+ }
107
+ if (r.type !== 'user' || !r.uuid || r.isMeta) continue; // meta rows ("Continue from where you left off.") are not turns
108
+ const c = r.message?.content;
109
+ const t = (typeof c === 'string' ? c : Array.isArray(c) ? c.filter((b) => b?.type === 'text').map((b) => b.text).join(' ') : '').replace(/\s+/g, ' ').trim();
110
+ // the same injected-row rule SCAN applies (pipeline/src/stages/scan.ts INJECTED): a
111
+ // compaction summary is not something the person said, and the preview listed it
112
+ if (!t || /^\s*<[a-z-]+>|^\s*\{"|^\[Request interrupted|^Caveat:|This session is being continued|<task-notification>|<system-reminder>/.test(t)) continue;
113
+ // a screenshot's companion row (a temp path, no words) is not a turn — scan drops it too
114
+ if (!t.replace(/\[Image: source: [^\]]*\]/g, '').replace(/\[이미지\]/g, '').trim()) continue;
115
+ const key = norm(t);
116
+ if (key.length > 16 && priorUsers.some((p) => key === p || (p.length >= 200 && key.startsWith(p)))) continue; // already recorded before the window
117
+ const text = ` ${clock(r.timestamp)} ${t.slice(0, 110)}${t.length > 110 ? '…' : ''}`;
118
+ if (pending && !pending.answered && nearDuplicate(pending.key, key)) {
119
+ // a resend of an unanswered turn takes its place
120
+ if (pending.at >= 0) lines[pending.at] = text;
121
+ pending.key = key; last = r.timestamp;
122
+ continue;
123
+ }
124
+ turns++; first ??= r.timestamp; last = r.timestamp;
125
+ let at = -1;
126
+ if (lines.length < max) { lines.push(text); at = lines.length - 1; }
127
+ pending = { at, key, answered: false };
128
+ }
129
+ return { turns, first, last, lines };
130
+ }
131
+
132
+ /**
133
+ * The server's DECISIONS about a capture — too soon after the last one, the day's
134
+ * count, the day's spend — are not failures: nothing is lost, the floor stays where it
135
+ * was and the work goes up with the next capture. Printed as the server said it, with
136
+ * exit 0, so the agent does not read a refusal it was told to expect as an error to
137
+ * report; and "too soon" says how long is left, which this machine keeps (state.mjs) so
138
+ * the next early call ends here without asking. Exported for the test.
139
+ */
140
+ // 'capture-other-device': another of the person's computers is sending right now; this one's work goes with its next
141
+ const DECISIONS = new Set(['capture-too-soon', 'daily-budget', 'capture-other-device', 'computer-accounts']);
142
+ export function refusal(status, bodyText, now = Date.now()) {
143
+ if (status !== 429) return null;
144
+ let b = null; try { b = JSON.parse(bodyText); } catch { return null; }
145
+ if (!DECISIONS.has(b?.code)) return null;
146
+ const sec = Number(b.retryAfterSec);
147
+ return { code: b.code, message: String(b.message ?? ''), until: b.code === 'capture-too-soon' && sec > 0 ? new Date(now + sec * 1000) : null };
148
+ }
149
+ async function refused(res) {
150
+ const text = await res.text().catch(() => '');
151
+ const r = refusal(res.status, text);
152
+ if (!r) { reportFail(res.status, text, '캡처'); process.exitCode = 1; return; }
153
+ if (r.until) { setNextCapture(r.until); notYet(r.until); return; }
154
+ say(marks.wait(), r.message, '지금까지의 작업은 다음 기록 때 함께 올라갑니다.');
155
+ }
156
+ /** the one answer to an early capture, whoever found out — this machine or the server */
157
+ function notYet(at) {
158
+ say(marks.wait(), `다음 기록은 ${when(at)} 이후에 가능합니다.`, '지금까지의 작업은 그때 함께 올라갑니다. 그 전에는 다시 실행하지 않아도 됩니다.');
159
+ }
160
+
161
+ // which transcript reader and server source a capture from `env` uses: the adapter registered under the agent's
162
+ // source. Claude Code is the default (a plain terminal, or an agent with no adapter), and its session is found by
163
+ // this module's `currentSession`, which knows how Claude Code names a folder's project dir.
164
+ function adapterFor(env = process.env) {
165
+ const agent = detectAgent(env);
166
+ const source = agentById(agent)?.source;
167
+ const ad = source && source !== 'cc' ? adapter(source) : null;
168
+ if (ad) return { agent, source, ad, session: (cwd) => ad.currentSession(cwd, env) };
169
+ return { agent: agent ?? 'cc', source: 'cc', ad: claudeCode, session: (cwd) => currentSession(cwd, env) };
170
+ }
171
+
172
+ export async function capture(argv = []) {
173
+ const cwd = process.cwd();
174
+ // --from/--to: re-record a stretch already behind the floor (a record extracted
175
+ // before its answer existed). The server clamps the window; the ref dedup keeps
176
+ // everything already owned from being stored twice.
177
+ const val = (name) => { const i = argv.indexOf(name); return i >= 0 && argv[i + 1] ? argv[i + 1] : undefined; };
178
+ const window = val('--from') && val('--to') ? { from: val('--from'), to: val('--to') } : undefined;
179
+ if ((val('--from') || val('--to')) && !window) { fail('--from 과 --to 는 함께 주세요 (ISO 시각).'); process.exitCode = 1; return; }
180
+ try { ensureInit(); } catch { /* a missing or stale skill is not a reason to lose a capture */ }
181
+ // the person's two local controls, before anything is read
182
+ const paused = pausedSince();
183
+ if (paused) { say(marks.wait(), '일시정지 중입니다. 기록하지 않습니다.', `${when(paused)} 부터 · 다시 켜려면 diffin resume`); return; }
184
+ // The server said when the next capture may go up, and this machine kept it: an early call ends here, with no
185
+ // request. This is what makes the agent's own judgment cheap — it cannot know when the person last recorded (another
186
+ // window, a cleared conversation), so it may ask, and asking costs nothing.
187
+ const early = nextCaptureAt();
188
+ if (early) { notYet(early); return; }
189
+ // which agent are we under? Only one whose transcript we can read may be captured.
190
+ const agent = detectAgent();
191
+ if (agent && !agentById(agent)?.capture) {
192
+ fail(`${agentById(agent)?.name ?? agent} 세션의 캡처는 아직 지원하지 않습니다.`, '지금은 Claude Code, Codex, Antigravity, Hermes 세션을 올릴 수 있습니다. 지원되면 npm install -g @diffin/cli 로 붙습니다.');
193
+ process.exitCode = 1; return;
194
+ }
195
+ // Codex ran this inside its sandbox: nothing can go up from here, and saying so beats a network error the agent
196
+ // would retry. `diffin init`, run once from a terminal, writes the rule that lets the command out (settings.mjs).
197
+ if (agent === 'codex' && !canWriteState()) {
198
+ say(marks.wait(), 'Codex 의 샌드박스 안이라 올릴 수 없습니다.', '터미널에서 diffin init 을 한 번 실행한 뒤 Codex 를 다시 시작하세요. 그리고 diffin capture 는 다른 명령과 묶지 말고 단독으로 실행해야 합니다.');
199
+ return;
200
+ }
201
+ // Every session on this machine is the default: whichever session asks, every
202
+ // session's new turns go (see captureAll). `--this` is the one-session form, and an
203
+ // explicit --from/--to window is always about one session. Each folder's own
204
+ // .diffin-ignore is checked per session there, so this folder's does not stop others.
205
+ if (argv.includes('--all') && window) { fail('--all 에는 --from/--to 를 함께 쓸 수 없습니다.'); process.exitCode = 1; return; }
206
+ if (!argv.includes('--this') && !window) return captureAll();
207
+ const excl = ignoredBy(cwd);
208
+ if (excl) { say(marks.wait(), '이 폴더는 기록하지 않습니다.', `${posixPath(scrub(excl))} 를 제외해 두었습니다. 되돌리려면 diffin include`); return; }
209
+ if (heldBy(cwd)) { say(marks.wait(), '이 폴더는 아직 기록하지 않습니다.', `${posixPath(scrub(cwd))} 는 허용한 폴더가 아닙니다. 허용하려면 터미널에서 diffin allow . (사람이 직접 실행합니다)`); return; }
210
+ const { agent: under, source, ad, session: find } = adapterFor();
211
+ const session = find(cwd);
212
+ if (!session) { fail(`이 폴더의 ${agentById(under).name} 세션을 찾지 못했습니다.`, scrub(cwd)); process.exitCode = 1; return; }
213
+
214
+ const token = tokenOrDie();
215
+ // a Hermes session names its own folder; a Claude Code one is found by the folder
216
+ const container = posixPath(scrub(session.cwd ?? cwd));
217
+ // the server owns the boundary: the later of this session's last recorded work and
218
+ // the source's watermark. The CLI only names the session and asks.
219
+ const res = await fetchRetry(`${SERVER}/backfill/runs`, () => ({
220
+ method: 'POST', headers: { authorization: `Bearer ${token}`, 'content-type': 'application/json' },
221
+ body: JSON.stringify({ source, device: deviceId(), ...(machineHash() ? { computer: machineHash() } : {}), capture: { container, session: session.sid, ...(window ?? {}) } }),
222
+ }));
223
+ if (!res.ok) { await refused(res); return; }
224
+ const { runId, machine, from, to, resumed, done } = await res.json();
225
+ pinRun(machine);
226
+ const cutOpts = { tsOf: ad.tsOf, fromMs: new Date(from).getTime(), toMs: new Date(to).getTime() };
227
+
228
+ // the entry for this one session, the way the adapter would list it
229
+ const entry = ad.enumerate({ from }).find((e) => e.sid === session.sid);
230
+ if (!entry) { say(marks.wait(), '이번 세션에 올릴 새 작업이 없습니다.'); await discard(token, runId, resumed); return; }
231
+
232
+ const p = await preview(entry.files[0].rows(), cutOpts);
233
+ const since = when(from);
234
+ if (!p.turns) { say(marks.wait(), `${since} 이후 새 작업이 없습니다.`); await discard(token, runId, resumed); return; }
235
+
236
+ const landed = new Set((done ?? []).find((d) => d.sid === session.sid)?.paths ?? []);
237
+ const projectDir = scrub(entry.projectDir);
238
+ let failed = 0;
239
+ for (const file of entry.files) {
240
+ if (landed.has(file.path)) continue;
241
+ const r = await putFile(token, runId, session.sid, projectDir, file, cutOpts);
242
+ if (r === 'failed') { failed++; break; }
243
+ if (r === 'empty' && file.path === 'main') break;
244
+ }
245
+ if (failed) { fail('올리지 못했습니다.', 'diffin capture 를 다시 실행하면 이어서 올립니다.'); process.exitCode = 1; return; }
246
+ const fin = await fetchRetry(`${SERVER}/backfill/runs/${runId}/complete`, () => ({ method: 'POST', headers: runAuth(token) }));
247
+ const fin2 = fin.ok ? await fin.json() : null;
248
+ if (fin2?.status === 'ready') { stepDone('올렸습니다', `발화 ${p.turns}개`, dim('몇 분 뒤 앱에 반영됩니다')); wentUp(fin2); }
249
+ else if (fin2?.status === 'empty') say(marks.wait(), '올릴 내용이 없었습니다.');
250
+ else { fail('완료 처리에 실패했습니다.', 'diffin capture 를 다시 실행하세요.'); process.exitCode = 1; }
251
+ }
252
+
253
+ /** how many sessions one capture may name — the server's own bound (backfill.controller.ts MAX_CAPTURE_SESSIONS),
254
+ * past which it refuses the whole request. With every agent's sessions offered the list is kept under it here:
255
+ * the sessions that moved last go, and the rest wait for the next capture, their floors where they were. */
256
+ const MAX_OFFERED = 200;
257
+ export const newestFirst = (offered, max = MAX_OFFERED) => [...offered].sort((a, b) => (b.e.last ?? 0) - (a.e.last ?? 0)).slice(0, max);
258
+
259
+ /**
260
+ * The sessions a capture of every session would send, and each one's cut. Exported
261
+ * for the tests: which sessions are offered, which are held back by a folder's
262
+ * .diffin-ignore, and that each is cut to its OWN floor.
263
+ */
264
+ export async function planAll(ad, floors, to, entries) {
265
+ const plan = [];
266
+ for (const e of entries) {
267
+ const from = floors?.[e.sid];
268
+ if (!from) continue;
269
+ const cut = { tsOf: ad.tsOf, fromMs: new Date(from).getTime(), toMs: new Date(to).getTime() };
270
+ const p = await preview(e.files[0].rows(), cut);
271
+ if (p.turns) plan.push({ entry: e, cut, p, from });
272
+ }
273
+ return plan;
274
+ }
275
+
276
+ /**
277
+ * Candidate sessions: moved recently, and not in a folder the person excluded (ignore.mjs — the folder it began in
278
+ * or the one it is in now, either of them under a `.diffin-ignore`).
279
+ */
280
+ export async function candidates(ad, now = Date.now(), { hold = false } = {}) {
281
+ return withoutIgnored(ad.enumerate({ from: new Date(now - ALL_LOOKBACK_MS).toISOString() }), { hold });
282
+ }
283
+
284
+ /**
285
+ * A machine with no allow list yet is converted without being asked (decided 2026-10-11): the folders of the sessions
286
+ * a capture would send now become the list, and from then on a folder under none of them is held. A machine with no
287
+ * sessions to read is left without a list — there is nothing to seed it from, and holding everything would be wrong.
288
+ */
289
+ async function seedAllowed() {
290
+ if (allowedFolders() !== null) return;
291
+ const dirs = [];
292
+ // both folders of a session — where it began and where it is — or a session that moved would be held by the seed it made
293
+ for (const ad of allAdapters()) { try { const { kept } = await candidates(ad); dirs.push(...kept.flatMap((e) => [e.cwd, e.cwd0]).filter(Boolean)); } catch { /* an unreadable agent seeds nothing */ } }
294
+ if (dirs.length) allowFolders(topFolders(dirs));
295
+ }
296
+
297
+ /** a scrubbed folder name (`~/x`) back to this machine's path — the app decides in the names the CLI reported */
298
+ const unscrub = (p) => (p === '~' ? homedir() : p.startsWith('~/') ? join(homedir(), p.slice(2)) : p);
299
+
300
+ /**
301
+ * The app's side of the allow list (`docs/folder-consent.md`, step 2): tell the server which folders this machine holds,
302
+ * allows and excludes (its deny list; a `.diffin-ignore` file is not a list) — names only — and take back the decisions
303
+ * the person made in the app: an allowance lifts an exclusion too, as `diffin include` would, and is dated by the
304
+ * decision's own time — only the work after it goes up. Nothing here may stop a capture: a server that cannot be
305
+ * reached, or an older server without the route, leaves the lists as they are.
306
+ */
307
+ async function syncFolders(token, heldAll) {
308
+ const allowed = allowedFolders();
309
+ if (allowed === null) return { changed: false };
310
+ const held = topFolders(heldAll.map((h) => h.folder).filter(Boolean)).map((f) => posixPath(scrub(f)));
311
+ let body = null;
312
+ try {
313
+ const res = await fetchRetry(`${SERVER}/folders/held`, () => ({ method: 'POST', headers: { authorization: `Bearer ${token}`, 'content-type': 'application/json' }, body: JSON.stringify({ device: deviceId(), held, allowed: allowed.map((f) => posixPath(scrub(f))), excluded: excludedFolders().map((f) => posixPath(scrub(f))) }) }), 1);
314
+ if (res.ok) body = await res.json();
315
+ } catch { /* the capture goes on with the lists this machine has */ }
316
+ const decisions = Array.isArray(body?.decisions) ? body.decisions : [];
317
+ if (!decisions.length) return { changed: false };
318
+ const applied = [];
319
+ for (const d of decisions) {
320
+ const dir = unscrub(String(d.path ?? '')); if (!dir) continue;
321
+ if (d.decision === 'allow') { includeFolder(dir); allowFolders([dir], Number.isNaN(Date.parse(String(d.at))) ? new Date() : new Date(String(d.at))); say(marks.done(), `${posixPath(scrub(dir))} 를 앱에서 허용했습니다. 허용한 뒤의 작업부터 올라갑니다.`); }
322
+ else if (d.decision === 'exclude') { excludeFolders([dir]); say(marks.wait(), `${posixPath(scrub(dir))} 를 앱에서 제외했습니다.`); }
323
+ else continue;
324
+ applied.push(String(d.id));
325
+ }
326
+ if (applied.length) { try { await fetchRetry(`${SERVER}/folders/applied`, () => ({ method: 'POST', headers: { authorization: `Bearer ${token}`, 'content-type': 'application/json' }, body: JSON.stringify({ device: deviceId(), ids: applied }) }), 1); } catch { /* reported next time: the server hands an unapplied decision back, and applying it again changes nothing */ } }
327
+ return { changed: applied.length > 0 };
328
+ }
329
+
330
+ /** the one line about folders waiting for the person — the agent relays it, the person acts on a terminal */
331
+ function sayHeld(held) {
332
+ const folders = topFolders(held.map((h) => h.folder).filter(Boolean));
333
+ if (!folders.length) return;
334
+ say(marks.wait(), `새 폴더 ${folders.length}곳은 아직 기록하지 않습니다 · ${folders.map((f) => posixPath(scrub(f))).join(' · ')}`,
335
+ '허용하려면 터미널에서 diffin allow <폴더> 또는 diffin folders (사람이 직접 실행합니다)');
336
+ }
337
+
338
+ /**
339
+ * `diffin capture` (the default; `--all` says it out loud) — every session's new turns,
340
+ * whichever session asks. `diffin capture --this` is the one-session form.
341
+ *
342
+ * People run several agent sessions at once, in several folders. A capture of one
343
+ * session left the others to wait for their own boundary, and the gap between
344
+ * captures (five hours; an hour when this was measured, twice in one afternoon) is per
345
+ * person — so one session's capture kept every other session from recording. Here each session is held
346
+ * to its own floor, which the server computes; the CLI names the sessions and when
347
+ * each last moved, and sends only what lies past each floor.
348
+ */
349
+ async function captureAll() {
350
+ const { source } = adapterFor();
351
+ const token = tokenOrDie();
352
+ // Every AGENT's sessions, not only the asking one's. The gap between captures is per person, so a capture that
353
+ // sent one agent's sessions kept the others' waiting for their own agent to ask after the gap — with three agents
354
+ // on a machine, most of a day's work. The run is the asker's; a session of another agent names its own.
355
+ await seedAllowed();
356
+ const gather = async () => {
357
+ const found = [], heldAll = [];
358
+ for (const ad of allAdapters()) {
359
+ let kept = [], held = [];
360
+ try { ({ kept, held } = await candidates(ad, Date.now(), { hold: true })); } catch { /* an agent whose sessions cannot be read holds nobody else back */ }
361
+ for (const e of kept) found.push({ ad, e });
362
+ heldAll.push(...held);
363
+ }
364
+ return { found, heldAll };
365
+ };
366
+ let { found, heldAll } = await gather();
367
+ // the app may have decided on a held folder since the last capture: apply, then look again with the lists as they are now
368
+ const { changed } = await syncFolders(token, heldAll);
369
+ if (changed) ({ found, heldAll } = await gather());
370
+ // a session of a folder the person allowed after the fact goes up from that moment: the later of its two folders' times
371
+ const sinceOf = (e) => { const ts = [e.cwd, e.cwd0].map((c) => allowedSince(c)).filter(Boolean).sort(); return ts.length ? ts[ts.length - 1] : null; };
372
+ const offered = newestFirst(found);
373
+ if (!offered.length) { say(marks.wait(), '최근 움직인 세션이 없습니다.'); sayHeld(heldAll); return; }
374
+ const res = await fetchRetry(`${SERVER}/backfill/runs`, () => ({
375
+ method: 'POST', headers: { authorization: `Bearer ${token}`, 'content-type': 'application/json' },
376
+ body: JSON.stringify({ source, device: deviceId(), ...(machineHash() ? { computer: machineHash() } : {}), capture: { all: true, sessions: offered.map(({ ad, e }) => ({ id: e.sid, ...(e.last ? { last: new Date(e.last).toISOString() } : {}), ...(sinceOf(e) ? { since: sinceOf(e) } : {}), ...(ad.name !== source ? { source: ad.name } : {}) })) } }),
377
+ }));
378
+ if (!res.ok) { await refused(res); return; }
379
+ const { runId, machine, to, floors, resumed, done } = await res.json();
380
+ pinRun(machine);
381
+ const plan = [];
382
+ for (const ad of allAdapters()) plan.push(...await planAll(ad, floors, to, offered.filter((o) => o.ad === ad).map((o) => o.e)));
383
+ if (!plan.length) {
384
+ say(marks.wait(), '모든 세션에 새 작업이 없습니다.');
385
+ sayHeld(heldAll);
386
+ await discard(token, runId, resumed);
387
+ return;
388
+ }
389
+
390
+ const total = plan.reduce((n, x) => n + x.p.turns, 0);
391
+ let failed = 0;
392
+ const sent = [];
393
+ for (const { entry, cut } of plan) {
394
+ const landed = new Set((done ?? []).find((d) => d.sid === entry.sid)?.paths ?? []);
395
+ const projectDir = scrub(entry.projectDir);
396
+ for (const file of entry.files) {
397
+ if (landed.has(file.path)) continue;
398
+ const r = await putFile(token, runId, entry.sid, projectDir, file, cut);
399
+ if (r === 'failed') { failed++; break; }
400
+ if (r === 'empty' && file.path === 'main') break;
401
+ }
402
+ if (failed) break;
403
+ sent.push(entry.sid);
404
+ }
405
+ if (failed) { fail('올리지 못했습니다.', 'diffin capture 를 다시 실행하면 이어서 올립니다.'); process.exitCode = 1; return; }
406
+ const fin = await fetchRetry(`${SERVER}/backfill/runs/${runId}/complete`, () => ({ method: 'POST', headers: runAuth(token) }));
407
+ const finBody = fin.ok ? await fin.json() : null;
408
+ const st = finBody?.status ?? null;
409
+ if (st === 'ready' || st === 'empty') {
410
+ const folders = new Set(plan.filter((x) => sent.includes(x.entry.sid)).map((x) => x.entry.cwd ?? x.entry.projectDir)).size;
411
+ if (st === 'ready') stepDone('올렸습니다', `폴더 ${folders}곳 · 발화 ${total}개`, dim('몇 분 뒤 앱에 반영됩니다')); else say(marks.wait(), '올릴 내용이 없었습니다.');
412
+ if (st === 'ready') wentUp(finBody);
413
+ sayHeld(heldAll);
414
+ } else { fail('완료 처리에 실패했습니다.', 'diffin capture 를 다시 실행하세요.'); process.exitCode = 1; }
415
+ }
416
+
417
+ /** a capture went up: keep the server's word on when the next may, and say it — the agent reads this line */
418
+ function wentUp(body) {
419
+ markSent();
420
+ const at = body?.nextCaptureAt ? new Date(body.nextCaptureAt) : null;
421
+ if (at && Number.isFinite(at.getTime())) { setNextCapture(at); say(marks.wait(), dim(`다음 기록은 ${when(at)} 이후에 가능합니다.`)); }
422
+ else setNextCapture(null);
423
+ }
424
+
425
+ // a run with nothing in it: tell the server so it is removed rather than left open
426
+ // (an open run would be handed back next time as "resume")
427
+ async function discard(token, runId, resumed) {
428
+ if (!runId || resumed) return; // nothing was opened; or a resumed run held files from before
429
+ await fetchRetry(`${SERVER}/backfill/runs/${runId}/complete`, () => ({ method: 'POST', headers: runAuth(token) })).catch(() => null);
430
+ }
431
+
432
+ /** `diffin pause` — off the record until `diffin resume`: nothing is sent, and what is said meanwhile never is */
433
+ export function pause() {
434
+ const was = pausedSince();
435
+ const since = pauseState();
436
+ if (was) say(marks.wait(), `이미 일시정지 중입니다. ${when(since)} 부터`, '다시 켜려면 diffin resume');
437
+ else say(marks.done(), '기록을 멈췄습니다.', '지금부터의 대화는 올라가지 않습니다. 다시 켜면 그 뒤의 대화부터 기록합니다.', '다시 켜려면 diffin resume');
438
+ }
439
+ export function resume() {
440
+ const from = resumeState();
441
+ if (from) say(marks.done(), '기록을 다시 켰습니다.', `${when(from)} 부터 지금까지의 대화는 올라가지 않습니다.`);
442
+ else say(marks.wait(), '일시정지 상태가 아니었습니다.');
443
+ }
@@ -0,0 +1,44 @@
1
+ // The `diffin` command on PATH — what the agent runs (`diffin capture`, the skill's one command) long after the person
2
+ // ran `npx @diffin/cli onboard` once. npx runs the package from its cache and leaves nothing on PATH, so without this
3
+ // the first capture an agent asked for answered "command not found" on every machine but the developer's own (where
4
+ // `npm link` had put one). Onboarding installs it globally, at the version that is running, unless a command of that
5
+ // version or newer is already there (a developer's link is newer or equal and is left alone).
6
+ import { spawnSync } from 'node:child_process';
7
+ import { readFileSync } from 'node:fs';
8
+ import { dirname, join } from 'node:path';
9
+ import { fileURLToPath } from 'node:url';
10
+
11
+ const win = process.platform === 'win32';
12
+ const pkg = () => JSON.parse(readFileSync(join(dirname(fileURLToPath(import.meta.url)), '..', 'package.json'), 'utf8'));
13
+ export const PACKAGE = () => pkg().name;
14
+
15
+ /** the version of the `diffin` on PATH, or null when there is none (or it does not answer) */
16
+ export function commandVersion() {
17
+ const r = spawnSync(win ? 'diffin.cmd' : 'diffin', ['--version'], { encoding: 'utf8', timeout: 15_000, shell: win, stdio: ['ignore', 'pipe', 'ignore'] });
18
+ const v = r.status === 0 ? String(r.stdout).trim().split('\n').pop() : '';
19
+ return /^\d+\.\d+\.\d+/.test(v) ? v : null;
20
+ }
21
+
22
+ /** a ≥ b, by numbers */
23
+ export function atLeast(a, b) {
24
+ const x = String(a).split(/[.-]/).map(Number), y = String(b).split(/[.-]/).map(Number);
25
+ for (let i = 0; i < 3; i++) { if ((x[i] || 0) !== (y[i] || 0)) return (x[i] || 0) > (y[i] || 0); }
26
+ return true;
27
+ }
28
+
29
+ /** install this package globally at `version`; returns null on success or the last lines npm printed */
30
+ export function installCommand(version) {
31
+ const r = spawnSync(win ? 'npm.cmd' : 'npm', ['install', '-g', `${PACKAGE()}@${version}`, '--no-fund', '--no-audit', '--loglevel=error'], { encoding: 'utf8', timeout: 300_000, shell: win, stdio: ['ignore', 'pipe', 'pipe'] });
32
+ if (r.status === 0) return null;
33
+ return (String(r.stderr || r.stdout || r.error?.message || '').trim().split('\n').slice(-2).join(' ') || `npm exited ${r.status}`).slice(0, 200);
34
+ }
35
+
36
+ /** make sure the agent can run `diffin`: { state: 'present' | 'installed' | 'failed', version, error? } */
37
+ export function ensureCommand(version) {
38
+ const have = commandVersion();
39
+ if (have && atLeast(have, version)) return { state: 'present', version: have };
40
+ const error = installCommand(version);
41
+ const now = commandVersion();
42
+ if (!error && now && atLeast(now, version)) return { state: 'installed', version: now };
43
+ return { state: 'failed', version: now, error: error ?? 'installed, but `diffin` is not on PATH' };
44
+ }
package/lib/creds.mjs ADDED
@@ -0,0 +1,35 @@
1
+ // Token storage. The CLI's credential does not expire (the server confines it to the upload routes instead), so
2
+ // there is nothing to renew. The token lives in ~/.diffin/credentials.json at
3
+ // 0600; DIFFIN_TOKEN in the environment overrides it (the ops runbook injects a
4
+ // per-run token, and testing injects one) and is never written back.
5
+ import { chmodSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
6
+ import { homedir } from 'node:os';
7
+ import { join } from 'node:path';
8
+ import { fail } from './ui.mjs';
9
+
10
+ const DIR = join(process.env.DIFFIN_HOME ?? homedir(), '.diffin');
11
+ const PATH = join(DIR, 'credentials.json');
12
+
13
+ export function readCreds() {
14
+ let saved = null;
15
+ try { saved = JSON.parse(readFileSync(PATH, 'utf8')); } catch { /* none yet */ }
16
+ if (process.env.DIFFIN_TOKEN) return { ...(saved ?? {}), token: process.env.DIFFIN_TOKEN };
17
+ return saved;
18
+ }
19
+
20
+ export function writeCreds(c) {
21
+ mkdirSync(DIR, { recursive: true, mode: 0o700 });
22
+ writeFileSync(PATH, JSON.stringify(c, null, 2) + '\n', { mode: 0o600 });
23
+ // `mode` applies only when the file is created: one that was there already keeps the permissions it had, and this
24
+ // token no longer expires. (Windows has no such bits; the profile folder's own protection stands.)
25
+ try { chmodSync(PATH, 0o600); } catch { /* not a filesystem that has them */ }
26
+ }
27
+
28
+ export function tokenOrDie() {
29
+ const t = readCreds()?.token;
30
+ if (!t) {
31
+ fail('로그인이 필요합니다.', 'diffin login <설치토큰> — 토큰은 앱의 연결 화면에 있습니다.');
32
+ process.exit(1);
33
+ }
34
+ return t;
35
+ }
@@ -0,0 +1,76 @@
1
+ // `diffin allow` · `diffin folders` — the person widens (or narrows) what a capture may send, on a TERMINAL only.
2
+ //
3
+ // The allow list (ignore.mjs, `docs/folder-consent.md`) is the person's; an agent runs commands without a terminal
4
+ // (stdin and stdout are not TTYs under Claude Code, Codex, Cursor), so a command that insists on one cannot be run by
5
+ // the agent that just asked for a capture — the same gate the onboarding's folder list already has. The capture's own
6
+ // result line names the held folders and these two commands; the agent relays the line, the person types.
7
+ import { resolve as resolvePath } from 'node:path';
8
+ import { allAdapters } from './sources/index.mjs';
9
+ import { ALL_LOOKBACK_MS, allowFolders, allowedFolders, allowedFrom, excludeFolders, excludedFrom, folderCensus, folderTree, heldBy, ignoreFileFor, includeFolder, topFolders, withoutIgnored } from './ignore.mjs';
10
+ import { posixPath, scrub } from './scrub.mjs';
11
+ import { dim, fail, ok, out, selectMany, spin, stepLive } from './ui.mjs';
12
+
13
+ const n = (x) => x.toLocaleString('ko-KR');
14
+ export const onTerminal = () => Boolean(process.stdin.isTTY && process.stdout.isTTY);
15
+ const needTerminal = (cmd) => { fail('사람이 터미널에서 실행해야 합니다.', `${cmd} 는 에이전트가 아니라 사람이 직접 터미널에서 실행하는 명령입니다. 터미널을 열어 같은 명령을 입력하세요.`); process.exitCode = 1; };
16
+
17
+ /** the sessions of every agent that moved recently, and the folders among them waiting to be allowed */
18
+ async function recent() {
19
+ const now = Date.now(), fromMs = now - ALL_LOOKBACK_MS;
20
+ const jobs = [], heldDirs = [];
21
+ for (const ad of allAdapters()) {
22
+ try {
23
+ const { kept, held } = await withoutIgnored(ad.enumerate({ from: new Date(fromMs).toISOString() }), { hold: true });
24
+ jobs.push({ ad, entries: [...kept, ...held.map((h) => h.entry)], cutOpts: { tsOf: ad.tsOf, fromMs, toMs: now } });
25
+ heldDirs.push(...held.map((h) => h.folder));
26
+ } catch { /* an agent whose sessions cannot be read is left out */ }
27
+ }
28
+ return { jobs, held: topFolders(heldDirs) };
29
+ }
30
+
31
+ /** `diffin allow [폴더 | . | --all]` — this folder and everything under it may be recorded from now on */
32
+ export async function allow(argv = []) {
33
+ if (!onTerminal()) return needTerminal('diffin allow');
34
+ let dirs;
35
+ if (argv.includes('--all')) {
36
+ dirs = (await recent()).held;
37
+ if (!dirs.length) { out(` ${dim('○')} 기다리는 폴더가 없습니다.`); return; }
38
+ } else dirs = [resolvePath(argv[0] ?? process.cwd())];
39
+ for (const dir of dirs) {
40
+ includeFolder(dir);
41
+ allowFolders([dir], new Date());
42
+ ok(`${posixPath(scrub(dir))} 와 그 아래 폴더를 기록합니다.`);
43
+ const file = ignoreFileFor(dir);
44
+ if (file) out(dim(` ${posixPath(scrub(file))} 에 .diffin-ignore 파일이 있어 그 아래는 계속 제외됩니다. 그 파일을 지우면 기록합니다.`));
45
+ }
46
+ out(dim(' 지금부터의 작업이 다음 캡처부터 올라갑니다. 허용 전의 대화는 올라가지 않습니다.'));
47
+ }
48
+
49
+ /**
50
+ * `diffin folders` — the onboarding's list again, over the folders recent sessions were made in: folders waiting to
51
+ * be allowed are marked and switched on, allowed ones on, excluded ones are not shown (they are `diffin include`'s).
52
+ * Enter keeps what is on and excludes what was switched off.
53
+ */
54
+ export async function folders() {
55
+ if (!onTerminal()) return needTerminal('diffin folders');
56
+ const stop = spin((frame) => stepLive(frame, '폴더 살펴보기', dim('어느 폴더에서 한 작업인지 읽고 있습니다')));
57
+ const turns = new Map();
58
+ let held = [];
59
+ try {
60
+ const r = await recent(); held = r.held;
61
+ for (const j of r.jobs) for (const [dir, c] of await folderCensus(j.entries, j.cutOpts)) turns.set(dir, (turns.get(dir) ?? 0) + c);
62
+ } finally { stop(); }
63
+ const tree = folderTree(turns);
64
+ if (!tree.length) { out(` ${dim('○')} 최근 작업한 폴더가 없습니다.`); return; }
65
+ const isHeld = (dir) => heldBy(dir);
66
+ const items = tree.map((it) => ({ label: it.parent == null ? posixPath(scrub(it.dir)) : posixPath(it.rel), tail: `발화 ${n(it.turns)}개${isHeld(it.dir) && (it.parent == null || !isHeld(tree[it.parent].dir)) ? ' · 새 폴더, 아직 기록하지 않음' : ''}`, parent: it.parent }));
67
+ const picked = await selectMany({ title: '기록할 폴더', note: `이 기기에서 최근 작업한 폴더입니다. 켜진 폴더와 그 아래만 기록합니다.${held.length ? ` 새 폴더 ${held.length}곳이 기다리고 있습니다.` : ''}`, items, initial: tree.map(() => true),
68
+ unit: (on) => `폴더 ${on.filter(Boolean).length}곳 기록` });
69
+ if (!picked) { out('취소했습니다. 바뀐 것은 없습니다.'); process.exitCode = 1; return; }
70
+ const on = allowedFrom(tree, picked), off = excludedFrom(tree, picked);
71
+ for (const d of on) includeFolder(d);
72
+ allowFolders(on, new Date()); // folders already allowed keep their time; the ones switched on now are allowed from now
73
+ if (off.length) excludeFolders(off);
74
+ ok(`기록하는 폴더 ${n((allowedFolders() ?? []).length)}곳${off.length ? ` · 제외한 폴더 ${off.map((f) => posixPath(scrub(f))).join(' · ')}` : ''}`);
75
+ out(dim(' 다음 캡처부터 적용됩니다 · 되돌리려면 diffin include <폴더> 또는 다시 diffin folders'));
76
+ }