@phnx-labs/agents-cli 1.20.14 → 1.20.16

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.
Files changed (41) hide show
  1. package/CHANGELOG.md +5 -0
  2. package/dist/commands/repo.js +22 -1
  3. package/dist/commands/secrets.js +53 -1
  4. package/dist/commands/sessions-sync.d.ts +13 -0
  5. package/dist/commands/sessions-sync.js +73 -0
  6. package/dist/commands/sessions.js +2 -0
  7. package/dist/commands/view.js +11 -3
  8. package/dist/index.js +1 -1
  9. package/dist/lib/agents.d.ts +11 -0
  10. package/dist/lib/agents.js +11 -9
  11. package/dist/lib/browser/service.js +28 -18
  12. package/dist/lib/daemon.d.ts +19 -0
  13. package/dist/lib/daemon.js +97 -2
  14. package/dist/lib/migrate.d.ts +22 -0
  15. package/dist/lib/migrate.js +99 -1
  16. package/dist/lib/plugin-marketplace.d.ts +15 -0
  17. package/dist/lib/plugin-marketplace.js +44 -0
  18. package/dist/lib/secrets/index.js +20 -0
  19. package/dist/lib/session/parse.d.ts +2 -0
  20. package/dist/lib/session/parse.js +168 -2
  21. package/dist/lib/session/sync/agents.d.ts +46 -0
  22. package/dist/lib/session/sync/agents.js +94 -0
  23. package/dist/lib/session/sync/config.d.ts +30 -0
  24. package/dist/lib/session/sync/config.js +58 -0
  25. package/dist/lib/session/sync/crdt.d.ts +44 -0
  26. package/dist/lib/session/sync/crdt.js +119 -0
  27. package/dist/lib/session/sync/manifest.d.ts +51 -0
  28. package/dist/lib/session/sync/manifest.js +96 -0
  29. package/dist/lib/session/sync/r2.d.ts +32 -0
  30. package/dist/lib/session/sync/r2.js +121 -0
  31. package/dist/lib/session/sync/sync.d.ts +82 -0
  32. package/dist/lib/session/sync/sync.js +251 -0
  33. package/dist/lib/shims.d.ts +1 -1
  34. package/dist/lib/shims.js +29 -3
  35. package/dist/lib/state.d.ts +18 -0
  36. package/dist/lib/state.js +73 -0
  37. package/dist/lib/teams/parsers.js +159 -1
  38. package/dist/lib/usage.d.ts +18 -0
  39. package/dist/lib/usage.js +25 -0
  40. package/dist/lib/versions.js +30 -13
  41. package/package.json +2 -1
@@ -0,0 +1,251 @@
1
+ /**
2
+ * Cross-machine session sync orchestration.
3
+ *
4
+ * PUSH this machine's transcripts (changed since last tick) to its own R2
5
+ * prefix, then publish a manifest. Single-writer prefixes mean no two
6
+ * machines ever write the same object — zero remote contention.
7
+ *
8
+ * PULL every other machine's manifest, fetch changed transcripts, CRDT-union
9
+ * copies of the same session, and write the result into the local mirror
10
+ * (a scan root). The existing scanner indexes it; sessions present in the
11
+ * live home always win, so the mirror only fills in remote-origin sessions.
12
+ *
13
+ * Invariants:
14
+ * - Live home transcripts are only ever READ + uploaded, never rewritten.
15
+ * - Union is idempotent, so once sets match nothing is transferred (quiescence).
16
+ */
17
+ import * as fs from 'fs';
18
+ import * as path from 'path';
19
+ import { R2Client } from './r2.js';
20
+ import { loadR2Config, machineId } from './config.js';
21
+ import { SYNC_AGENTS, listLocalTranscripts, localSessionIds, mirrorPath, objectKey, manifestKey, SESSIONS_PREFIX, } from './agents.js';
22
+ import { mergeTranscripts, transcriptStats } from './crdt.js';
23
+ import { emptyManifest, parseManifest, hashContent, loadLedger, saveLedger, ledgerUnchanged, ledgerRecord, loadLocalManifest, saveLocalManifest, loadPullState, savePullState, sourceSignature, } from './manifest.js';
24
+ const nowIso = () => new Date().toISOString();
25
+ function specById(id) {
26
+ return SYNC_AGENTS.find(s => s.id === id);
27
+ }
28
+ /** Upload this machine's changed transcripts and publish its manifest. */
29
+ async function pushOwn(r2, me, opts, result) {
30
+ const ledger = loadLedger();
31
+ const prev = loadLocalManifest();
32
+ const manifest = emptyManifest(me, nowIso());
33
+ for (const spec of SYNC_AGENTS) {
34
+ const agentManifest = {};
35
+ for (const t of listLocalTranscripts(spec)) {
36
+ let stat;
37
+ try {
38
+ stat = fs.statSync(t.absPath);
39
+ }
40
+ catch {
41
+ continue;
42
+ }
43
+ const prevEntry = prev?.agents?.[spec.id]?.[t.sessionId];
44
+ if (prevEntry && ledgerUnchanged(ledger, t.absPath, stat.size, stat.mtimeMs)) {
45
+ agentManifest[t.sessionId] = prevEntry; // unchanged: reuse, no read, no upload
46
+ result.pushSkipped++;
47
+ continue;
48
+ }
49
+ let content;
50
+ try {
51
+ content = fs.readFileSync(t.absPath, 'utf-8');
52
+ }
53
+ catch {
54
+ continue;
55
+ }
56
+ const hash = hashContent(content);
57
+ const { lastTs } = transcriptStats(content);
58
+ const entry = { relKey: t.relKey, size: stat.size, hash, lastTs };
59
+ try {
60
+ await r2.put(objectKey(me, spec.id, t.sessionId), content, 'application/x-ndjson');
61
+ ledgerRecord(ledger, t.absPath, stat.size, stat.mtimeMs, hash);
62
+ agentManifest[t.sessionId] = entry;
63
+ result.pushed++;
64
+ if (opts.verbose)
65
+ opts.log?.(` push ${spec.id}/${t.sessionId.slice(0, 8)} (${stat.size}B)`);
66
+ }
67
+ catch (err) {
68
+ result.errors.push(`push ${spec.id}/${t.sessionId}: ${err.message}`);
69
+ }
70
+ }
71
+ if (Object.keys(agentManifest).length > 0)
72
+ manifest.agents[spec.id] = agentManifest;
73
+ }
74
+ try {
75
+ await r2.put(manifestKey(me), JSON.stringify(manifest), 'application/json');
76
+ saveLocalManifest(manifest);
77
+ saveLedger(ledger);
78
+ }
79
+ catch (err) {
80
+ result.errors.push(`manifest publish: ${err.message}`);
81
+ }
82
+ }
83
+ /**
84
+ * Decide which remote sessions need fetching this tick. Pure: no I/O.
85
+ * Skips sessions we hold locally (home wins) and sessions whose source set is
86
+ * unchanged since we last materialized them (pull-state hit → quiescence).
87
+ */
88
+ export function selectSessionsToFetch(copies, localIdsByAgent, pullState) {
89
+ const pending = [];
90
+ for (const [agentId, byAgent] of copies) {
91
+ const localIds = localIdsByAgent.get(agentId) ?? new Set();
92
+ for (const [sessionId, list] of byAgent) {
93
+ if (localIds.has(sessionId))
94
+ continue;
95
+ const sig = sourceSignature(list.map(c => c.entry.hash));
96
+ if (pullState[`${agentId}/${sessionId}`] === sig)
97
+ continue;
98
+ pending.push({ agentId, sessionId, copies: list, sig });
99
+ }
100
+ }
101
+ return pending;
102
+ }
103
+ /**
104
+ * Resolve the mirror destination + merged content for one session. Pure.
105
+ * The canonical path comes from the lexicographically-smallest machine so every
106
+ * puller derives an identical location; the content is the CRDT union of copies.
107
+ */
108
+ export function resolveMirrorWrite(spec, copies, contents) {
109
+ const canonical = [...copies].sort((a, b) => (a.machine < b.machine ? -1 : a.machine > b.machine ? 1 : 0))[0];
110
+ const content = contents.length === 1 ? contents[0] : mergeTranscripts(contents);
111
+ return {
112
+ dest: mirrorPath(spec, canonical.machine, canonical.entry.relKey),
113
+ content,
114
+ merged: contents.length > 1,
115
+ };
116
+ }
117
+ /**
118
+ * Decide how to reconcile one session's fetched copies. Pure: no I/O.
119
+ *
120
+ * `fetched` is positionally aligned to `copies` — a `null` slot means that
121
+ * copy's object wasn't retrievable this tick (R2 404 / LIST→GET consistency
122
+ * lag / a transient get error). If ANY listed copy is missing, returns `null`:
123
+ * the caller must then skip the mirror write AND skip stamping pull-state, so
124
+ * the session is retried next tick. Writing a partial set instead would
125
+ * materialize a non-converged union and — because pull-state would record the
126
+ * full source signature — abandon the missing branch forever (the bug this
127
+ * guards: a signature match in selectSessionsToFetch never re-fetches it).
128
+ */
129
+ export function reconcileCopies(spec, copies, fetched) {
130
+ const contents = [];
131
+ for (const text of fetched) {
132
+ if (text === null)
133
+ return null; // incomplete fetch — retry next tick
134
+ contents.push(text);
135
+ }
136
+ if (contents.length === 0)
137
+ return null;
138
+ return resolveMirrorWrite(spec, copies, contents);
139
+ }
140
+ /** Fetch other machines' manifests, union changed sessions into the mirror. */
141
+ async function pullAndReconcile(r2, me, opts, result) {
142
+ const prefixes = await r2.listPrefixes(SESSIONS_PREFIX); // sessions/<machine>/
143
+ const machines = prefixes
144
+ .map(p => p.slice(SESSIONS_PREFIX.length).replace(/\/$/, ''))
145
+ .filter(m => m && m !== me);
146
+ // agentId -> sessionId -> copies across machines
147
+ const copies = new Map();
148
+ for (const m of machines) {
149
+ let manifest = null;
150
+ try {
151
+ const text = await r2.get(manifestKey(m));
152
+ manifest = text ? parseManifest(text) : null;
153
+ }
154
+ catch (err) {
155
+ result.errors.push(`fetch manifest ${m}: ${err.message}`);
156
+ }
157
+ if (!manifest)
158
+ continue;
159
+ for (const [agentId, sessions] of Object.entries(manifest.agents)) {
160
+ if (!specById(agentId))
161
+ continue;
162
+ let byAgent = copies.get(agentId);
163
+ if (!byAgent)
164
+ copies.set(agentId, (byAgent = new Map()));
165
+ for (const [sessionId, entry] of Object.entries(sessions)) {
166
+ const list = byAgent.get(sessionId) ?? [];
167
+ list.push({ machine: m, entry });
168
+ byAgent.set(sessionId, list);
169
+ }
170
+ }
171
+ }
172
+ const pullState = loadPullState();
173
+ const localIdsByAgent = new Map();
174
+ for (const agentId of copies.keys()) {
175
+ const spec = specById(agentId);
176
+ if (spec)
177
+ localIdsByAgent.set(agentId, localSessionIds(spec));
178
+ }
179
+ const pending = selectSessionsToFetch(copies, localIdsByAgent, pullState);
180
+ let candidates = 0;
181
+ for (const byAgent of copies.values())
182
+ candidates += byAgent.size;
183
+ result.pullSkipped += candidates - pending.length; // local-owned or unchanged
184
+ for (const { agentId, sessionId, copies: list, sig } of pending) {
185
+ const spec = specById(agentId);
186
+ // Download each copy (could be a fork across >1 machine), keeping the
187
+ // result positionally aligned to `list` — null marks a copy we couldn't
188
+ // fetch this tick (404 / consistency lag / error).
189
+ const fetched = [];
190
+ for (const c of list) {
191
+ try {
192
+ fetched.push(await r2.get(objectKey(c.machine, agentId, sessionId)));
193
+ }
194
+ catch (err) {
195
+ result.errors.push(`get ${c.machine}/${sessionId}: ${err.message}`);
196
+ fetched.push(null);
197
+ }
198
+ }
199
+ // null ⇒ an incomplete fetch: skip the write AND the pull-state stamp so we
200
+ // retry next tick instead of persisting a partial union / abandoning a branch.
201
+ const resolved = reconcileCopies(spec, list, fetched);
202
+ if (!resolved)
203
+ continue;
204
+ const { dest, content, merged } = resolved;
205
+ try {
206
+ let existing = null;
207
+ try {
208
+ existing = fs.readFileSync(dest, 'utf-8');
209
+ }
210
+ catch { /* not present yet */ }
211
+ if (existing !== content) {
212
+ fs.mkdirSync(path.dirname(dest), { recursive: true });
213
+ fs.writeFileSync(dest, content, 'utf-8');
214
+ if (merged)
215
+ result.merged++;
216
+ result.pulled++;
217
+ if (opts.verbose) {
218
+ opts.log?.(` pull ${agentId}/${sessionId.slice(0, 8)} <- ${list.map(c => c.machine).join('+')}`);
219
+ }
220
+ }
221
+ else {
222
+ result.pullSkipped++;
223
+ }
224
+ pullState[`${agentId}/${sessionId}`] = sig;
225
+ }
226
+ catch (err) {
227
+ result.errors.push(`write mirror ${sessionId}: ${err.message}`);
228
+ }
229
+ }
230
+ savePullState(pullState);
231
+ }
232
+ /** Run one full sync cycle: push this machine's changes, pull everyone else's. */
233
+ export async function syncSessions(opts = {}) {
234
+ const cfg = loadR2Config();
235
+ const me = machineId();
236
+ const r2 = new R2Client(cfg);
237
+ const result = {
238
+ machine: me,
239
+ pushed: 0,
240
+ pushSkipped: 0,
241
+ pulled: 0,
242
+ merged: 0,
243
+ pullSkipped: 0,
244
+ errors: [],
245
+ };
246
+ if (opts.push !== false)
247
+ await pushOwn(r2, me, opts, result);
248
+ if (opts.pull !== false)
249
+ await pullAndReconcile(r2, me, opts, result);
250
+ return result;
251
+ }
@@ -77,7 +77,7 @@ export interface ConflictInfo {
77
77
  * top-level entry add/remove — deep edits to plugin contents won't
78
78
  * trigger auto-resync, run `agents sync` for that.
79
79
  */
80
- export declare const SHIM_SCHEMA_VERSION = 18;
80
+ export declare const SHIM_SCHEMA_VERSION = 19;
81
81
  /**
82
82
  * Generate the full bash shim script for the given agent. The returned string
83
83
  * is written to ~/.agents/shims/{cliCommand} and made executable.
package/dist/lib/shims.js CHANGED
@@ -202,7 +202,7 @@ async function promptConflictStrategy(conflictInfos) {
202
202
  * top-level entry add/remove — deep edits to plugin contents won't
203
203
  * trigger auto-resync, run `agents sync` for that.
204
204
  */
205
- export const SHIM_SCHEMA_VERSION = 18;
205
+ export const SHIM_SCHEMA_VERSION = 19;
206
206
  /** Internal marker string used to embed the schema version in shim scripts. */
207
207
  const SHIM_VERSION_MARKER = 'agents-shim-version:';
208
208
  function shellQuote(value) {
@@ -414,6 +414,22 @@ elif [ "$AGENT" = "kimi" ]; then
414
414
  # Last resort: whatever is on PATH
415
415
  BINARY=$(command -v kimi 2>/dev/null || echo "")
416
416
  fi
417
+ # Droid (Factory AI) special case: the official installer drops a standalone
418
+ # native binary at ~/.local/bin/droid — there is no npm package and nothing
419
+ # lands in node_modules/.bin. Resolve the fixed install path directly. The
420
+ # PATH fallback explicitly refuses anything under our own shims dir: that path
421
+ # IS this dispatcher, so exec'ing it would re-enter and spin in an infinite
422
+ # re-exec loop (the bug this branch fixes).
423
+ elif [ "$AGENT" = "droid" ]; then
424
+ DROID_BINARY="$HOME/.local/bin/droid"
425
+ if [ -x "$DROID_BINARY" ]; then
426
+ BINARY="$DROID_BINARY"
427
+ else
428
+ BINARY=$(command -v droid 2>/dev/null || echo "")
429
+ case "$BINARY" in
430
+ "$AGENTS_USER_DIR/.cache/shims/"*) BINARY="" ;;
431
+ esac
432
+ fi
417
433
  else
418
434
  BINARY="$VERSION_DIR/node_modules/.bin/$CLI_COMMAND"
419
435
  fi
@@ -685,7 +701,10 @@ export function ensureVersionedAliasCurrent(agent, version) {
685
701
  createVersionedAlias(agent, version);
686
702
  return 'created';
687
703
  }
688
- if (!isVersionedAliasCurrent(agent, version)) {
704
+ // Upgrade-only (newest-wins), same rationale as ensureShimCurrent: never
705
+ // downgrade an alias stamped by a newer install sharing the shims dir.
706
+ const onDisk = readVersionedAliasSchemaVersion(agent, version);
707
+ if (onDisk === null || onDisk < VERSIONED_ALIAS_SCHEMA_VERSION) {
689
708
  createVersionedAlias(agent, version);
690
709
  return 'updated';
691
710
  }
@@ -1278,7 +1297,14 @@ export function ensureShimCurrent(agent) {
1278
1297
  createShim(agent);
1279
1298
  return 'created';
1280
1299
  }
1281
- if (!isShimCurrent(agent)) {
1300
+ // Upgrade-only (newest-wins): regenerate only when the on-disk shim is
1301
+ // unversioned/unreadable (null) or OLDER than this binary. Never downgrade a
1302
+ // shim stamped by a NEWER agents-cli install. Two installs at different
1303
+ // SHIM_SCHEMA_VERSION sharing ~/.agents/.cache/shims/ (e.g. a dev build on
1304
+ // PATH alongside a Hermes-bundled published copy) otherwise ping-pong —
1305
+ // rewriting every shim on each alternating launch and adding boot latency.
1306
+ const onDisk = readShimSchemaVersion(agent);
1307
+ if (onDisk === null || onDisk < SHIM_SCHEMA_VERSION) {
1282
1308
  createShim(agent);
1283
1309
  return 'updated';
1284
1310
  }
@@ -247,6 +247,24 @@ export declare function recordVersionResources(_agent: AgentId, _version: string
247
247
  * Pass all resource types you want to initialize in one call to batch the write.
248
248
  */
249
249
  export declare function ensureVersionResourcePatterns(agent: AgentId, version: string, updates: Partial<Record<Exclude<keyof VersionResources, 'rulesPreset'>, ResourcePattern[]>>): void;
250
+ /**
251
+ * Insert `<alias>:*` at the canonical position (after the system/user/other-extra
252
+ * includes, before `project:*`), unless the alias is already referenced — as an
253
+ * include (`alias:...`) or an exclude (`!alias:...`). Returns a new array when it
254
+ * changes, otherwise the same reference (so callers can detect no-ops cheaply).
255
+ */
256
+ export declare function withAlias(list: ResourcePattern[], alias: string): ResourcePattern[];
257
+ /** Strip every reference to `<alias>:...` / `!<alias>:...` from a selector list. */
258
+ export declare function withoutAlias(list: ResourcePattern[], alias: string): ResourcePattern[];
259
+ /**
260
+ * Backfill (add=true) or strip (add=false) an extra-repo alias across every
261
+ * already-installed version's selectors. New versions get the alias via
262
+ * `defaultPatterns()` at scaffold time; this keeps existing versions in sync
263
+ * when an extra repo is registered/enabled or removed. Only touches selector
264
+ * lists that are already set — an unset list is left for `defaultPatterns()`.
265
+ * Returns the number of (agent, version) pairs changed.
266
+ */
267
+ export declare function applyExtraAliasToVersions(alias: string, add: boolean): number;
250
268
  export declare function getVersionResources(agent: AgentId, version: string): VersionResources | null;
251
269
  /** Active rules preset for an agent@version. Defaults to "default" when unset. */
252
270
  export declare function getActiveRulesPreset(agent: AgentId, version: string): string;
package/dist/lib/state.js CHANGED
@@ -711,6 +711,79 @@ export function ensureVersionResourcePatterns(agent, version, updates) {
711
711
  if (changed)
712
712
  writeMeta(meta);
713
713
  }
714
+ /**
715
+ * Resource types that resolve across the extra-repo layer. Mirrors
716
+ * `defaultPatterns()`: extras feed commands/skills/hooks/subagents/plugins/
717
+ * workflows, but never permissions (`system:*`) or mcp (`user:*`).
718
+ */
719
+ const EXTRA_ELIGIBLE_TYPES = [
720
+ 'commands', 'skills', 'hooks', 'subagents', 'plugins', 'workflows',
721
+ ];
722
+ /**
723
+ * Insert `<alias>:*` at the canonical position (after the system/user/other-extra
724
+ * includes, before `project:*`), unless the alias is already referenced — as an
725
+ * include (`alias:...`) or an exclude (`!alias:...`). Returns a new array when it
726
+ * changes, otherwise the same reference (so callers can detect no-ops cheaply).
727
+ */
728
+ export function withAlias(list, alias) {
729
+ const prefix = `${alias}:`;
730
+ if (list.some(p => p === `${alias}:*` || p.startsWith(prefix) || p.startsWith(`!${prefix}`))) {
731
+ return list;
732
+ }
733
+ const next = [...list];
734
+ const projIdx = next.findIndex(p => p === 'project:*' || p.startsWith('project:'));
735
+ if (projIdx >= 0)
736
+ next.splice(projIdx, 0, `${alias}:*`);
737
+ else
738
+ next.push(`${alias}:*`);
739
+ return next;
740
+ }
741
+ /** Strip every reference to `<alias>:...` / `!<alias>:...` from a selector list. */
742
+ export function withoutAlias(list, alias) {
743
+ const prefix = `${alias}:`;
744
+ const next = list.filter(p => !(p.startsWith(prefix) || p.startsWith(`!${prefix}`)));
745
+ return next.length === list.length ? list : next;
746
+ }
747
+ /**
748
+ * Backfill (add=true) or strip (add=false) an extra-repo alias across every
749
+ * already-installed version's selectors. New versions get the alias via
750
+ * `defaultPatterns()` at scaffold time; this keeps existing versions in sync
751
+ * when an extra repo is registered/enabled or removed. Only touches selector
752
+ * lists that are already set — an unset list is left for `defaultPatterns()`.
753
+ * Returns the number of (agent, version) pairs changed.
754
+ */
755
+ export function applyExtraAliasToVersions(alias, add) {
756
+ const meta = readMeta();
757
+ if (!meta.versions)
758
+ return 0;
759
+ let changed = false;
760
+ let count = 0;
761
+ for (const versions of Object.values(meta.versions)) {
762
+ if (!versions)
763
+ continue;
764
+ for (const vr of Object.values(versions)) {
765
+ if (!vr)
766
+ continue;
767
+ let touched = false;
768
+ for (const type of EXTRA_ELIGIBLE_TYPES) {
769
+ const cur = vr[type];
770
+ if (!Array.isArray(cur) || cur.length === 0)
771
+ continue;
772
+ const next = add ? withAlias(cur, alias) : withoutAlias(cur, alias);
773
+ if (next !== cur) {
774
+ vr[type] = next;
775
+ touched = true;
776
+ changed = true;
777
+ }
778
+ }
779
+ if (touched)
780
+ count++;
781
+ }
782
+ }
783
+ if (changed)
784
+ writeMeta(meta);
785
+ return count;
786
+ }
714
787
  export function getVersionResources(agent, version) {
715
788
  const meta = readMeta();
716
789
  return meta.versions?.[agent]?.[version] || null;
@@ -2,7 +2,7 @@
2
2
  * Agent event stream parsers.
3
3
  *
4
4
  * Normalizes the heterogeneous JSON event formats emitted by each agent CLI
5
- * (Claude, Codex, Gemini, Cursor, OpenCode, Grok, Antigravity) into a unified
5
+ * (Claude, Codex, Gemini, Cursor, OpenCode, Grok, Antigravity, Kimi) into a unified
6
6
  * event schema with consistent types: init, message, tool_use, bash,
7
7
  * file_read, file_write, file_create, file_delete, result, error, and others.
8
8
  */
@@ -31,6 +31,9 @@ export function normalizeEvents(agentType, raw) {
31
31
  else if (agentType === 'antigravity') {
32
32
  return normalizeAntigravity(raw);
33
33
  }
34
+ else if (agentType === 'kimi') {
35
+ return normalizeKimi(raw);
36
+ }
34
37
  // droid (Factory AI) intentionally falls through to the generic normalizer
35
38
  // below: its `-o stream-json` JSONL event schema is not yet verified against
36
39
  // a live run (the documented `debug` format differs from stream-json). Events
@@ -908,6 +911,161 @@ function normalizeGrok(raw) {
908
911
  timestamp: timestamp,
909
912
  }];
910
913
  }
914
+ // --- Kimi parsing ---
915
+ // Kimi's `--output-format stream-json` emits one JSON object per line with a
916
+ // simple `role`-based schema:
917
+ // - {"role":"assistant","content":"..."} → final message
918
+ // - {"role":"assistant","tool_calls":[{"function":{"name":"Bash","arguments":"<json>"}}]} → tool use
919
+ // - {"role":"tool","tool_call_id":"...","content":"..."} → tool result
920
+ // - {"role":"meta","type":"session.resume_hint","session_id":"..."} → init / session id
921
+ // Tool arguments are JSON-stringified inside `function.arguments` and must be
922
+ // parsed before extracting paths/commands. Verified against live `kimi` runs.
923
+ function normalizeKimi(raw) {
924
+ const timestamp = new Date().toISOString();
925
+ if (!raw || typeof raw !== 'object') {
926
+ return [{
927
+ type: 'unknown',
928
+ agent: 'kimi',
929
+ raw: raw,
930
+ timestamp: timestamp,
931
+ }];
932
+ }
933
+ const role = typeof raw.role === 'string' ? raw.role : '';
934
+ // Assistant message (final answer or tool-call request).
935
+ if (role === 'assistant') {
936
+ const events = [];
937
+ if (typeof raw.content === 'string' && raw.content) {
938
+ events.push({
939
+ type: 'message',
940
+ agent: 'kimi',
941
+ content: raw.content,
942
+ complete: true,
943
+ timestamp: timestamp,
944
+ });
945
+ }
946
+ const toolCalls = Array.isArray(raw.tool_calls) ? raw.tool_calls : [];
947
+ for (const toolCall of toolCalls) {
948
+ const fn = toolCall?.function || {};
949
+ const toolName = typeof fn.name === 'string' ? fn.name : 'unknown';
950
+ let toolArgs = {};
951
+ if (typeof fn.arguments === 'string') {
952
+ try {
953
+ toolArgs = JSON.parse(fn.arguments);
954
+ }
955
+ catch {
956
+ toolArgs = { _raw: fn.arguments };
957
+ }
958
+ }
959
+ else if (fn.arguments && typeof fn.arguments === 'object') {
960
+ toolArgs = fn.arguments;
961
+ }
962
+ const filePath = toolArgs?.path || toolArgs?.file_path || '';
963
+ const command = toolArgs?.command || '';
964
+ // Map known tools to structured events. If a known tool is missing the
965
+ // fields we need (e.g. unparseable arguments), fall back to tool_use so
966
+ // the event is still visible in summaries rather than dropped.
967
+ let normalized = null;
968
+ if (toolName === 'Bash' && command) {
969
+ const bashEvents = [{
970
+ type: 'bash',
971
+ agent: 'kimi',
972
+ tool: toolName,
973
+ command: command,
974
+ timestamp: timestamp,
975
+ }];
976
+ const [filesRead, filesWritten, filesDeleted] = extractFileOpsFromBash(command);
977
+ for (const p of filesRead) {
978
+ bashEvents.push({ type: 'file_read', agent: 'kimi', tool: 'bash', path: p, command, timestamp });
979
+ }
980
+ for (const p of filesWritten) {
981
+ bashEvents.push({ type: 'file_write', agent: 'kimi', tool: 'bash', path: p, command, timestamp });
982
+ }
983
+ for (const p of filesDeleted) {
984
+ bashEvents.push({ type: 'file_delete', agent: 'kimi', tool: 'bash', path: p, command, timestamp });
985
+ }
986
+ normalized = bashEvents;
987
+ }
988
+ else if (toolName === 'Read' && filePath) {
989
+ normalized = [{
990
+ type: 'file_read',
991
+ agent: 'kimi',
992
+ tool: toolName,
993
+ path: filePath,
994
+ timestamp: timestamp,
995
+ }];
996
+ }
997
+ else if (toolName === 'Edit' && filePath) {
998
+ normalized = [{
999
+ type: 'file_write',
1000
+ agent: 'kimi',
1001
+ tool: toolName,
1002
+ path: filePath,
1003
+ timestamp: timestamp,
1004
+ }];
1005
+ }
1006
+ else if (toolName === 'Write' && filePath) {
1007
+ normalized = [{
1008
+ type: 'file_create',
1009
+ agent: 'kimi',
1010
+ tool: toolName,
1011
+ path: filePath,
1012
+ timestamp: timestamp,
1013
+ }];
1014
+ }
1015
+ if (normalized) {
1016
+ events.push(...normalized);
1017
+ }
1018
+ else {
1019
+ events.push({
1020
+ type: 'tool_use',
1021
+ agent: 'kimi',
1022
+ tool: toolName,
1023
+ args: toolArgs,
1024
+ timestamp: timestamp,
1025
+ });
1026
+ }
1027
+ }
1028
+ return events.length > 0 ? events : [];
1029
+ }
1030
+ // Tool result (response to an assistant tool_call).
1031
+ if (role === 'tool') {
1032
+ const content = typeof raw.content === 'string' ? raw.content : '';
1033
+ const success = raw.isError !== true && !(content && content.startsWith('Error:'));
1034
+ return [{
1035
+ type: 'tool_result',
1036
+ agent: 'kimi',
1037
+ tool_call_id: typeof raw.tool_call_id === 'string' ? raw.tool_call_id : null,
1038
+ success: success,
1039
+ content: content,
1040
+ timestamp: timestamp,
1041
+ }];
1042
+ }
1043
+ // Meta events (session lifecycle).
1044
+ if (role === 'meta') {
1045
+ const metaType = typeof raw.type === 'string' ? raw.type : '';
1046
+ if (metaType === 'session.resume_hint') {
1047
+ return [{
1048
+ type: 'init',
1049
+ agent: 'kimi',
1050
+ session_id: typeof raw.session_id === 'string' ? raw.session_id : null,
1051
+ timestamp: timestamp,
1052
+ }];
1053
+ }
1054
+ return [{
1055
+ type: 'meta',
1056
+ agent: 'kimi',
1057
+ meta_type: metaType,
1058
+ raw: raw,
1059
+ timestamp: timestamp,
1060
+ }];
1061
+ }
1062
+ return [{
1063
+ type: raw.type || 'unknown',
1064
+ agent: 'kimi',
1065
+ raw: raw,
1066
+ timestamp: timestamp,
1067
+ }];
1068
+ }
911
1069
  // --- Antigravity parsing ---
912
1070
  // Intentionally conservative. Antigravity's `agy` binary advertises an
913
1071
  // `--output-format json` flag in its docs, but the released binary errors with
@@ -85,6 +85,24 @@ export declare function getUsageInfoByIdentity(inputs: UsageIdentityInput[]): Pr
85
85
  export declare function getUsageInfoForIdentity(input: UsageIdentityInput): Promise<UsageInfo>;
86
86
  /** Format a one-line usage summary with compact bars for inline display. */
87
87
  export declare function formatUsageSummary(plan: string | null, snapshot: UsageSnapshot | null, planWidth?: number): string;
88
+ /**
89
+ * Derive an account's real throttle state from its live usage windows — the
90
+ * same signal `agents usage` shows and balanced rotation trusts
91
+ * (`getRoutingUsedPercent` in rotate.ts). A window at 100% utilization means
92
+ * the account is throttled until that window resets.
93
+ *
94
+ * Returns `null` when there is no snapshot, so callers render no badge rather
95
+ * than a misleading one. This deliberately never consults
96
+ * `cachedExtraUsageDisabledReason`: that field describes why pay-as-you-go
97
+ * overage is disabled (`out_of_credits` = no overage credits purchased,
98
+ * `org_level_disabled` = an admin turned overage off), NOT whether the account
99
+ * can do work right now. A Pro account at 5% weekly usage with overage disabled
100
+ * is fully usable, yet that flag would mislabel it "out of credits".
101
+ *
102
+ * The model-specific `sonnet_week` sub-limit is excluded: hitting it throttles
103
+ * one model, not the account, so it shouldn't flip the whole row to throttled.
104
+ */
105
+ export declare function deriveUsageStatusFromSnapshot(snapshot: UsageSnapshot | null | undefined): 'available' | 'rate_limited' | null;
88
106
  /**
89
107
  * Compact colored badge for the account's overall usage status. Renders only
90
108
  * when the account is throttled — `available` and `null` return ''.
package/dist/lib/usage.js CHANGED
@@ -201,6 +201,31 @@ export function formatUsageSummary(plan, snapshot, planWidth = 3) {
201
201
  }
202
202
  return parts.join(' ');
203
203
  }
204
+ /**
205
+ * Derive an account's real throttle state from its live usage windows — the
206
+ * same signal `agents usage` shows and balanced rotation trusts
207
+ * (`getRoutingUsedPercent` in rotate.ts). A window at 100% utilization means
208
+ * the account is throttled until that window resets.
209
+ *
210
+ * Returns `null` when there is no snapshot, so callers render no badge rather
211
+ * than a misleading one. This deliberately never consults
212
+ * `cachedExtraUsageDisabledReason`: that field describes why pay-as-you-go
213
+ * overage is disabled (`out_of_credits` = no overage credits purchased,
214
+ * `org_level_disabled` = an admin turned overage off), NOT whether the account
215
+ * can do work right now. A Pro account at 5% weekly usage with overage disabled
216
+ * is fully usable, yet that flag would mislabel it "out of credits".
217
+ *
218
+ * The model-specific `sonnet_week` sub-limit is excluded: hitting it throttles
219
+ * one model, not the account, so it shouldn't flip the whole row to throttled.
220
+ */
221
+ export function deriveUsageStatusFromSnapshot(snapshot) {
222
+ if (!snapshot || snapshot.windows.length === 0)
223
+ return null;
224
+ const blocking = snapshot.windows.filter((window) => window.key !== 'sonnet_week');
225
+ const windows = blocking.length > 0 ? blocking : snapshot.windows;
226
+ const maxUsed = Math.max(...windows.map((window) => window.usedPercent));
227
+ return maxUsed >= 100 ? 'rate_limited' : 'available';
228
+ }
204
229
  /**
205
230
  * Compact colored badge for the account's overall usage status. Renders only
206
231
  * when the account is throttled — `available` and `null` return ''.