@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.
- package/CHANGELOG.md +5 -0
- package/dist/commands/repo.js +22 -1
- package/dist/commands/secrets.js +53 -1
- package/dist/commands/sessions-sync.d.ts +13 -0
- package/dist/commands/sessions-sync.js +73 -0
- package/dist/commands/sessions.js +2 -0
- package/dist/commands/view.js +11 -3
- package/dist/index.js +1 -1
- package/dist/lib/agents.d.ts +11 -0
- package/dist/lib/agents.js +11 -9
- package/dist/lib/browser/service.js +28 -18
- package/dist/lib/daemon.d.ts +19 -0
- package/dist/lib/daemon.js +97 -2
- package/dist/lib/migrate.d.ts +22 -0
- package/dist/lib/migrate.js +99 -1
- package/dist/lib/plugin-marketplace.d.ts +15 -0
- package/dist/lib/plugin-marketplace.js +44 -0
- package/dist/lib/secrets/index.js +20 -0
- package/dist/lib/session/parse.d.ts +2 -0
- package/dist/lib/session/parse.js +168 -2
- package/dist/lib/session/sync/agents.d.ts +46 -0
- package/dist/lib/session/sync/agents.js +94 -0
- package/dist/lib/session/sync/config.d.ts +30 -0
- package/dist/lib/session/sync/config.js +58 -0
- package/dist/lib/session/sync/crdt.d.ts +44 -0
- package/dist/lib/session/sync/crdt.js +119 -0
- package/dist/lib/session/sync/manifest.d.ts +51 -0
- package/dist/lib/session/sync/manifest.js +96 -0
- package/dist/lib/session/sync/r2.d.ts +32 -0
- package/dist/lib/session/sync/r2.js +121 -0
- package/dist/lib/session/sync/sync.d.ts +82 -0
- package/dist/lib/session/sync/sync.js +251 -0
- package/dist/lib/shims.d.ts +1 -1
- package/dist/lib/shims.js +29 -3
- package/dist/lib/state.d.ts +18 -0
- package/dist/lib/state.js +73 -0
- package/dist/lib/teams/parsers.js +159 -1
- package/dist/lib/usage.d.ts +18 -0
- package/dist/lib/usage.js +25 -0
- package/dist/lib/versions.js +30 -13
- package/package.json +2 -1
package/dist/lib/migrate.d.ts
CHANGED
|
@@ -25,6 +25,28 @@
|
|
|
25
25
|
* LEGACY_SYSTEM_DIR" without duplicating data.
|
|
26
26
|
*/
|
|
27
27
|
export declare function foldLegacySystemRepo(): void;
|
|
28
|
+
/**
|
|
29
|
+
* Repair self-referential agent binary symlinks.
|
|
30
|
+
*
|
|
31
|
+
* Some installScript-based agents — notably Factory's `droid`, whose installer
|
|
32
|
+
* drops a standalone native binary at ~/.local/bin/droid — were registered at
|
|
33
|
+
* install time by resolving the post-install binary with `which <cli>`. Because
|
|
34
|
+
* ~/.agents/.cache/shims sits ahead of ~/.local/bin on PATH, `which` could
|
|
35
|
+
* return OUR OWN dispatcher shim, and the install step symlinked
|
|
36
|
+
* ~/.agents/.history/versions/<agent>/<version>/node_modules/.bin/<cli>
|
|
37
|
+
* back at ~/.agents/.cache/shims/<cli>. Launching that agent then re-execs the
|
|
38
|
+
* dispatcher forever (an infinite exec loop that hangs the terminal).
|
|
39
|
+
*
|
|
40
|
+
* This walks every installed version's node_modules/.bin and, for any entry
|
|
41
|
+
* whose symlink resolves into the shims dir, re-points it at the real binary
|
|
42
|
+
* (found on PATH with the shims dir excluded) — or removes it when no real
|
|
43
|
+
* binary can be found, letting getBinaryPath's per-agent resolver take over.
|
|
44
|
+
* Idempotent: a correctly-pointed link is left untouched on re-run.
|
|
45
|
+
*
|
|
46
|
+
* Params default to the real on-disk locations; they are injectable so tests
|
|
47
|
+
* can drive a fixture tree without touching the user's ~/.agents.
|
|
48
|
+
*/
|
|
49
|
+
export declare function repairSelfReferentialBinShims(versionsRoot?: string, shimsDir?: string): void;
|
|
28
50
|
/**
|
|
29
51
|
* Rename the legacy `extras-extras/` plugin-marketplace dir to `agents-extras/`
|
|
30
52
|
* inside every installed agent version-home, and rewrite cross-references in
|
package/dist/lib/migrate.js
CHANGED
|
@@ -8,7 +8,7 @@ import * as fs from 'fs';
|
|
|
8
8
|
import * as path from 'path';
|
|
9
9
|
import * as os from 'os';
|
|
10
10
|
import * as yaml from 'yaml';
|
|
11
|
-
import { AGENTS, agentConfigDirName } from './agents.js';
|
|
11
|
+
import { AGENTS, agentConfigDirName, findInPath } from './agents.js';
|
|
12
12
|
const HOME = process.env.HOME ?? os.homedir();
|
|
13
13
|
const USER_DIR = path.join(HOME, '.agents');
|
|
14
14
|
/** Canonical system-repo location (post-fold). */
|
|
@@ -715,6 +715,100 @@ function repairAgentConfigSymlinks() {
|
|
|
715
715
|
console.error(`Repaired ${repaired} agent config symlink${repaired === 1 ? '' : 's'} to point at ~/.agents/versions/`);
|
|
716
716
|
}
|
|
717
717
|
}
|
|
718
|
+
/**
|
|
719
|
+
* Repair self-referential agent binary symlinks.
|
|
720
|
+
*
|
|
721
|
+
* Some installScript-based agents — notably Factory's `droid`, whose installer
|
|
722
|
+
* drops a standalone native binary at ~/.local/bin/droid — were registered at
|
|
723
|
+
* install time by resolving the post-install binary with `which <cli>`. Because
|
|
724
|
+
* ~/.agents/.cache/shims sits ahead of ~/.local/bin on PATH, `which` could
|
|
725
|
+
* return OUR OWN dispatcher shim, and the install step symlinked
|
|
726
|
+
* ~/.agents/.history/versions/<agent>/<version>/node_modules/.bin/<cli>
|
|
727
|
+
* back at ~/.agents/.cache/shims/<cli>. Launching that agent then re-execs the
|
|
728
|
+
* dispatcher forever (an infinite exec loop that hangs the terminal).
|
|
729
|
+
*
|
|
730
|
+
* This walks every installed version's node_modules/.bin and, for any entry
|
|
731
|
+
* whose symlink resolves into the shims dir, re-points it at the real binary
|
|
732
|
+
* (found on PATH with the shims dir excluded) — or removes it when no real
|
|
733
|
+
* binary can be found, letting getBinaryPath's per-agent resolver take over.
|
|
734
|
+
* Idempotent: a correctly-pointed link is left untouched on re-run.
|
|
735
|
+
*
|
|
736
|
+
* Params default to the real on-disk locations; they are injectable so tests
|
|
737
|
+
* can drive a fixture tree without touching the user's ~/.agents.
|
|
738
|
+
*/
|
|
739
|
+
export function repairSelfReferentialBinShims(versionsRoot = path.join(HISTORY_DIR, 'versions'), shimsDir = path.resolve(CACHE_DIR, 'shims')) {
|
|
740
|
+
// Normalize the shims dir through realpath so the prefix check below survives
|
|
741
|
+
// a symlinked ~/.agents (or macOS's /tmp -> /private/tmp): fs.realpathSync on
|
|
742
|
+
// the link target resolves those symlinks, so the dir we compare against must
|
|
743
|
+
// too, or every loop would read as "points at a real binary" and be skipped.
|
|
744
|
+
shimsDir = path.resolve(shimsDir);
|
|
745
|
+
try {
|
|
746
|
+
shimsDir = fs.realpathSync(shimsDir);
|
|
747
|
+
}
|
|
748
|
+
catch {
|
|
749
|
+
/* shims dir absent — leave the resolved path; nothing will match it */
|
|
750
|
+
}
|
|
751
|
+
let agents;
|
|
752
|
+
try {
|
|
753
|
+
agents = fs.readdirSync(versionsRoot);
|
|
754
|
+
}
|
|
755
|
+
catch {
|
|
756
|
+
return; // no versions installed yet
|
|
757
|
+
}
|
|
758
|
+
let repaired = 0;
|
|
759
|
+
for (const agent of agents) {
|
|
760
|
+
const cli = agent in AGENTS ? AGENTS[agent].cliCommand : agent;
|
|
761
|
+
let versions;
|
|
762
|
+
try {
|
|
763
|
+
versions = fs.readdirSync(path.join(versionsRoot, agent));
|
|
764
|
+
}
|
|
765
|
+
catch {
|
|
766
|
+
continue;
|
|
767
|
+
}
|
|
768
|
+
for (const version of versions) {
|
|
769
|
+
const binLink = path.join(versionsRoot, agent, version, 'node_modules', '.bin', cli);
|
|
770
|
+
let stat;
|
|
771
|
+
try {
|
|
772
|
+
stat = fs.lstatSync(binLink);
|
|
773
|
+
}
|
|
774
|
+
catch {
|
|
775
|
+
continue; // no .bin entry for this version
|
|
776
|
+
}
|
|
777
|
+
if (!stat.isSymbolicLink())
|
|
778
|
+
continue;
|
|
779
|
+
let real;
|
|
780
|
+
try {
|
|
781
|
+
real = fs.realpathSync(binLink);
|
|
782
|
+
}
|
|
783
|
+
catch {
|
|
784
|
+
// Dangling symlink — if it was aimed at the shims dir it's the loop
|
|
785
|
+
// residue; drop it either way so getBinaryPath reports honestly.
|
|
786
|
+
try {
|
|
787
|
+
fs.unlinkSync(binLink);
|
|
788
|
+
repaired++;
|
|
789
|
+
}
|
|
790
|
+
catch { /* best-effort */ }
|
|
791
|
+
continue;
|
|
792
|
+
}
|
|
793
|
+
if (!path.resolve(real).startsWith(shimsDir + path.sep))
|
|
794
|
+
continue; // points at a real binary — fine
|
|
795
|
+
// Self-referential: the link resolves back into our own shims dir.
|
|
796
|
+
// findInPath does a pure-Node PATH scan (no subprocess) and already
|
|
797
|
+
// skips our shims dir, so it returns the genuine install if one exists.
|
|
798
|
+
const realBinary = findInPath(cli);
|
|
799
|
+
try {
|
|
800
|
+
fs.unlinkSync(binLink);
|
|
801
|
+
if (realBinary)
|
|
802
|
+
fs.symlinkSync(realBinary, binLink);
|
|
803
|
+
repaired++;
|
|
804
|
+
}
|
|
805
|
+
catch { /* best-effort */ }
|
|
806
|
+
}
|
|
807
|
+
}
|
|
808
|
+
if (repaired > 0) {
|
|
809
|
+
console.error(`Repaired ${repaired} self-referential agent binary symlink${repaired === 1 ? '' : 's'} (infinite exec-loop fix).`);
|
|
810
|
+
}
|
|
811
|
+
}
|
|
718
812
|
/**
|
|
719
813
|
* Move a directory from `src` to `dest`. No-op when src is absent. When dest
|
|
720
814
|
* already exists, merge by copying everything that isn't already there, then
|
|
@@ -1733,4 +1827,8 @@ export async function runMigration() {
|
|
|
1733
1827
|
migrateExtrasExtrasToAgentsExtras();
|
|
1734
1828
|
// Symlink repair runs LAST so it can find the post-move version homes.
|
|
1735
1829
|
repairAgentConfigSymlinks();
|
|
1830
|
+
// Repair self-referential node_modules/.bin/<cli> symlinks (the droid
|
|
1831
|
+
// infinite-exec-loop). Also runs after the bucket moves so it scans the
|
|
1832
|
+
// canonical HISTORY_DIR/versions tree.
|
|
1833
|
+
repairSelfReferentialBinShims();
|
|
1736
1834
|
}
|
|
@@ -98,6 +98,21 @@ export declare function knownMarketplacesPath(agent: AgentId, versionHome: strin
|
|
|
98
98
|
* Internal symlinks (target stays inside the plugin root) are preserved.
|
|
99
99
|
*/
|
|
100
100
|
export declare function copyPluginToMarketplace(plugin: DiscoveredPlugin, spec: MarketplaceSpec | string, agent: AgentId, versionHome: string): string;
|
|
101
|
+
/**
|
|
102
|
+
* Claude Code's plugin-manifest schema requires the resource path fields to be
|
|
103
|
+
* relative paths starting with "./" — `skills`/`commands`/`agents` are
|
|
104
|
+
* `union([startsWith("./"), array(startsWith("./"))])` (verified against the
|
|
105
|
+
* Claude Code binary). Bare names like "loop" fail validation and Claude rejects
|
|
106
|
+
* the ENTIRE plugin at load time, surfacing only in its `/plugin` > Errors tab.
|
|
107
|
+
*
|
|
108
|
+
* agents-cli copies plugin.json verbatim into the marketplace, so a malformed
|
|
109
|
+
* manifest ships looking fully installed while loading nothing. This catches the
|
|
110
|
+
* unambiguous type violation (non-"./" string entries) and returns one warning
|
|
111
|
+
* per offending field so the caller can surface it loudly. `hooks`/`mcpServers`
|
|
112
|
+
* are intentionally skipped — they legitimately accept inline objects, so a path
|
|
113
|
+
* check would false-positive.
|
|
114
|
+
*/
|
|
115
|
+
export declare function validateClaudePluginManifest(manifest: unknown): string[];
|
|
101
116
|
/**
|
|
102
117
|
* Re-synthesize <marketplace>/.claude-plugin/marketplace.json from the plugins
|
|
103
118
|
* already installed under <marketplace>/plugins/. Always run after add or remove
|
|
@@ -185,6 +185,47 @@ export function copyPluginToMarketplace(plugin, spec, agent, versionHome) {
|
|
|
185
185
|
}
|
|
186
186
|
return dest;
|
|
187
187
|
}
|
|
188
|
+
// ─── Manifest validation ─────────────────────────────────────────────────────
|
|
189
|
+
/**
|
|
190
|
+
* Claude Code's plugin-manifest schema requires the resource path fields to be
|
|
191
|
+
* relative paths starting with "./" — `skills`/`commands`/`agents` are
|
|
192
|
+
* `union([startsWith("./"), array(startsWith("./"))])` (verified against the
|
|
193
|
+
* Claude Code binary). Bare names like "loop" fail validation and Claude rejects
|
|
194
|
+
* the ENTIRE plugin at load time, surfacing only in its `/plugin` > Errors tab.
|
|
195
|
+
*
|
|
196
|
+
* agents-cli copies plugin.json verbatim into the marketplace, so a malformed
|
|
197
|
+
* manifest ships looking fully installed while loading nothing. This catches the
|
|
198
|
+
* unambiguous type violation (non-"./" string entries) and returns one warning
|
|
199
|
+
* per offending field so the caller can surface it loudly. `hooks`/`mcpServers`
|
|
200
|
+
* are intentionally skipped — they legitimately accept inline objects, so a path
|
|
201
|
+
* check would false-positive.
|
|
202
|
+
*/
|
|
203
|
+
export function validateClaudePluginManifest(manifest) {
|
|
204
|
+
const warnings = [];
|
|
205
|
+
if (!manifest || typeof manifest !== 'object')
|
|
206
|
+
return warnings;
|
|
207
|
+
const m = manifest;
|
|
208
|
+
for (const field of ['skills', 'commands', 'agents']) {
|
|
209
|
+
const value = m[field];
|
|
210
|
+
if (value === undefined || value === null)
|
|
211
|
+
continue;
|
|
212
|
+
const entries = Array.isArray(value) ? value : [value];
|
|
213
|
+
for (const entry of entries) {
|
|
214
|
+
if (typeof entry !== 'string') {
|
|
215
|
+
warnings.push(`plugin.json field "${field}" must contain relative paths starting with "./" ` +
|
|
216
|
+
`(e.g. "./${field}/<name>"); found a non-string value. Claude Code will reject the whole plugin.`);
|
|
217
|
+
break;
|
|
218
|
+
}
|
|
219
|
+
if (!entry.startsWith('./')) {
|
|
220
|
+
warnings.push(`plugin.json field "${field}" entry "${entry}" must be a relative path starting with "./" ` +
|
|
221
|
+
`(e.g. "./${field}/${entry}"). Claude Code rejects the entire plugin otherwise — ` +
|
|
222
|
+
`remove the field to auto-discover from ${field}/, or use relative paths.`);
|
|
223
|
+
break;
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
return warnings;
|
|
228
|
+
}
|
|
188
229
|
// ─── Catalog synthesis ──────────────────────────────────────────────────────
|
|
189
230
|
/**
|
|
190
231
|
* Re-synthesize <marketplace>/.claude-plugin/marketplace.json from the plugins
|
|
@@ -226,6 +267,9 @@ export function syncMarketplaceManifest(spec, agent, versionHome) {
|
|
|
226
267
|
catch {
|
|
227
268
|
continue;
|
|
228
269
|
}
|
|
270
|
+
for (const warning of validateClaudePluginManifest(manifest)) {
|
|
271
|
+
process.stderr.write(`agents-cli: plugin '${manifest.name ?? entry.name}': ${warning}\n`);
|
|
272
|
+
}
|
|
229
273
|
entries.push({
|
|
230
274
|
name: manifest.name,
|
|
231
275
|
source: `./plugins/${manifest.name}`,
|
|
@@ -236,6 +236,26 @@ export function setKeychainToken(item, value) {
|
|
|
236
236
|
linuxBackend.set(item, value);
|
|
237
237
|
return;
|
|
238
238
|
}
|
|
239
|
+
// Bare (non-`agents-cli.`) items are written WITHOUT the biometry ACL so
|
|
240
|
+
// they round-trip with the no-prompt read path in getKeychainToken (which
|
|
241
|
+
// also uses /usr/bin/security for non-our items). This is what lets a
|
|
242
|
+
// SessionStart hook read e.g. `linear-api-key` silently on every launch.
|
|
243
|
+
// Routing these through the helper would attach a Touch ID ACL that the
|
|
244
|
+
// /usr/bin/security read can't satisfy without popping the legacy password
|
|
245
|
+
// sheet. -U upserts so repeated sets overwrite in place.
|
|
246
|
+
if (!isOurItem(item)) {
|
|
247
|
+
const sec = spawnSync('/usr/bin/security', [
|
|
248
|
+
'add-generic-password', '-U',
|
|
249
|
+
'-a', os.userInfo().username,
|
|
250
|
+
'-s', item,
|
|
251
|
+
'-w', value,
|
|
252
|
+
], { stdio: ['ignore', 'pipe', 'pipe'] });
|
|
253
|
+
if (sec.status !== 0) {
|
|
254
|
+
const msg = sec.stderr?.toString().trim();
|
|
255
|
+
throw new Error(msg || `Failed to write keychain item '${item}'.`);
|
|
256
|
+
}
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
239
259
|
const bin = getKeychainHelperPath();
|
|
240
260
|
const result = spawnSync(bin, ['set', item, os.userInfo().username], {
|
|
241
261
|
input: value,
|
|
@@ -49,3 +49,5 @@ export declare function parseOpenCode(filePath: string): SessionEvent[];
|
|
|
49
49
|
export declare function parseRush(filePath: string): SessionEvent[];
|
|
50
50
|
/** Parse a Hermes session JSON file into normalized events. */
|
|
51
51
|
export declare function parseHermes(filePath: string): SessionEvent[];
|
|
52
|
+
/** Parse a Kimi session state.json file by reading its agents/main/wire.jsonl. */
|
|
53
|
+
export declare function parseKimi(filePath: string): SessionEvent[];
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
* objects suitable for rendering, filtering, and summarization.
|
|
7
7
|
*/
|
|
8
8
|
import * as fs from 'fs';
|
|
9
|
+
import * as path from 'path';
|
|
9
10
|
import { execFileSync } from 'child_process';
|
|
10
11
|
/**
|
|
11
12
|
* Largest session file we will load into memory. Above this we throw a clean
|
|
@@ -106,8 +107,8 @@ export function parseSession(filePath, agent) {
|
|
|
106
107
|
events = parseHermes(filePath);
|
|
107
108
|
break;
|
|
108
109
|
case 'kimi':
|
|
109
|
-
events =
|
|
110
|
-
break;
|
|
110
|
+
events = parseKimi(filePath);
|
|
111
|
+
break;
|
|
111
112
|
}
|
|
112
113
|
// Chokepoint: every string field that originated in an untrusted session
|
|
113
114
|
// file gets stripped of terminal escapes here, so renderers downstream can
|
|
@@ -130,6 +131,8 @@ export function detectAgent(filePath) {
|
|
|
130
131
|
return 'rush';
|
|
131
132
|
if (filePath.includes('/.hermes/') || filePath.includes('\\.hermes\\'))
|
|
132
133
|
return 'hermes';
|
|
134
|
+
if (filePath.includes('/.kimi-code/') || filePath.includes('\\.kimi-code\\'))
|
|
135
|
+
return 'kimi';
|
|
133
136
|
// Cloud convention: cloud-sessions/<id>/session.<format>.jsonl
|
|
134
137
|
const cloudMatch = filePath.match(/session\.(claude|codex|rush)\.jsonl(?:$|[?#])/);
|
|
135
138
|
if (cloudMatch)
|
|
@@ -958,3 +961,166 @@ function hermesContentToText(content) {
|
|
|
958
961
|
.join('\n')
|
|
959
962
|
.trim();
|
|
960
963
|
}
|
|
964
|
+
// ---------------------------------------------------------------------------
|
|
965
|
+
// Kimi parser
|
|
966
|
+
//
|
|
967
|
+
// Kimi stores session metadata in state.json and the conversation transcript
|
|
968
|
+
// in agents/main/wire.jsonl under ~/.kimi-code/sessions/<workdir>/session_<uuid>/.
|
|
969
|
+
// wire.jsonl uses a role-based schema:
|
|
970
|
+
// - "context.append_message" with role=user/assistant -> messages
|
|
971
|
+
// - "context.append_loop_event" with content.part type=text/think -> message/thinking
|
|
972
|
+
// - "context.append_loop_event" with event.type=tool.call -> tool_use
|
|
973
|
+
// - "context.append_loop_event" with event.type=tool.result -> tool_result
|
|
974
|
+
// - "usage.record" -> usage
|
|
975
|
+
// ---------------------------------------------------------------------------
|
|
976
|
+
/** Parse a Kimi session state.json file by reading its agents/main/wire.jsonl. */
|
|
977
|
+
export function parseKimi(filePath) {
|
|
978
|
+
const sessionDir = path.dirname(filePath);
|
|
979
|
+
const wirePath = path.join(sessionDir, 'agents', 'main', 'wire.jsonl');
|
|
980
|
+
if (!fs.existsSync(wirePath)) {
|
|
981
|
+
return [];
|
|
982
|
+
}
|
|
983
|
+
const content = safeReadSessionFile(wirePath);
|
|
984
|
+
const lines = content.split('\n').filter(l => l.trim());
|
|
985
|
+
const events = [];
|
|
986
|
+
// Map tool.call uuid -> tool name so tool.result can carry the tool name.
|
|
987
|
+
const toolCallMap = new Map();
|
|
988
|
+
function extractMessageText(rawContent) {
|
|
989
|
+
if (typeof rawContent === 'string')
|
|
990
|
+
return rawContent.trim();
|
|
991
|
+
if (Array.isArray(rawContent)) {
|
|
992
|
+
return rawContent
|
|
993
|
+
.map((part) => (typeof part?.text === 'string' ? part.text : ''))
|
|
994
|
+
.join('')
|
|
995
|
+
.trim();
|
|
996
|
+
}
|
|
997
|
+
return '';
|
|
998
|
+
}
|
|
999
|
+
function timestampFrom(raw) {
|
|
1000
|
+
const t = raw?.time;
|
|
1001
|
+
if (typeof t === 'number' && t > 0) {
|
|
1002
|
+
return new Date(t).toISOString();
|
|
1003
|
+
}
|
|
1004
|
+
return new Date().toISOString();
|
|
1005
|
+
}
|
|
1006
|
+
for (const line of lines) {
|
|
1007
|
+
let raw;
|
|
1008
|
+
try {
|
|
1009
|
+
raw = JSON.parse(line);
|
|
1010
|
+
}
|
|
1011
|
+
catch {
|
|
1012
|
+
continue;
|
|
1013
|
+
}
|
|
1014
|
+
const type = raw?.type;
|
|
1015
|
+
const timestamp = timestampFrom(raw);
|
|
1016
|
+
if (type === 'context.append_message') {
|
|
1017
|
+
const message = raw.message || {};
|
|
1018
|
+
const role = message.role === 'user' ? 'user' : 'assistant';
|
|
1019
|
+
const text = extractMessageText(message.content);
|
|
1020
|
+
if (!text)
|
|
1021
|
+
continue;
|
|
1022
|
+
events.push({
|
|
1023
|
+
type: 'message',
|
|
1024
|
+
agent: 'kimi',
|
|
1025
|
+
timestamp,
|
|
1026
|
+
role,
|
|
1027
|
+
content: text,
|
|
1028
|
+
});
|
|
1029
|
+
}
|
|
1030
|
+
else if (type === 'context.append_loop_event') {
|
|
1031
|
+
const event = raw.event || {};
|
|
1032
|
+
const eventType = event.type;
|
|
1033
|
+
if (eventType === 'content.part') {
|
|
1034
|
+
const part = event.part || {};
|
|
1035
|
+
const partType = part.type;
|
|
1036
|
+
if (partType === 'text') {
|
|
1037
|
+
const text = typeof part.text === 'string' ? part.text.trim() : '';
|
|
1038
|
+
if (text) {
|
|
1039
|
+
events.push({
|
|
1040
|
+
type: 'message',
|
|
1041
|
+
agent: 'kimi',
|
|
1042
|
+
timestamp,
|
|
1043
|
+
role: 'assistant',
|
|
1044
|
+
content: text,
|
|
1045
|
+
});
|
|
1046
|
+
}
|
|
1047
|
+
}
|
|
1048
|
+
else if (partType === 'think') {
|
|
1049
|
+
const think = typeof part.think === 'string' ? part.think.trim() : '';
|
|
1050
|
+
if (think) {
|
|
1051
|
+
events.push({
|
|
1052
|
+
type: 'thinking',
|
|
1053
|
+
agent: 'kimi',
|
|
1054
|
+
timestamp,
|
|
1055
|
+
content: think,
|
|
1056
|
+
});
|
|
1057
|
+
}
|
|
1058
|
+
}
|
|
1059
|
+
}
|
|
1060
|
+
else if (eventType === 'tool.call') {
|
|
1061
|
+
const fn = event.function || {};
|
|
1062
|
+
const toolName = typeof event.name === 'string' ? event.name : (fn.name || 'unknown');
|
|
1063
|
+
let args = {};
|
|
1064
|
+
if (typeof fn.arguments === 'string') {
|
|
1065
|
+
try {
|
|
1066
|
+
args = JSON.parse(fn.arguments);
|
|
1067
|
+
}
|
|
1068
|
+
catch {
|
|
1069
|
+
args = { _raw: fn.arguments };
|
|
1070
|
+
}
|
|
1071
|
+
}
|
|
1072
|
+
else if (fn.arguments && typeof fn.arguments === 'object') {
|
|
1073
|
+
args = fn.arguments;
|
|
1074
|
+
}
|
|
1075
|
+
const callId = event.toolCallId || event.uuid;
|
|
1076
|
+
if (callId) {
|
|
1077
|
+
toolCallMap.set(callId, toolName);
|
|
1078
|
+
}
|
|
1079
|
+
events.push({
|
|
1080
|
+
type: 'tool_use',
|
|
1081
|
+
agent: 'kimi',
|
|
1082
|
+
timestamp,
|
|
1083
|
+
tool: toolName,
|
|
1084
|
+
args,
|
|
1085
|
+
path: args.path || args.file_path || undefined,
|
|
1086
|
+
command: toolName === 'Bash' ? args.command : undefined,
|
|
1087
|
+
});
|
|
1088
|
+
}
|
|
1089
|
+
else if (eventType === 'tool.result') {
|
|
1090
|
+
const callId = event.toolCallId || event.parentUuid;
|
|
1091
|
+
const toolName = (callId && toolCallMap.get(callId)) || 'unknown';
|
|
1092
|
+
const result = event.result || {};
|
|
1093
|
+
const output = typeof result.output === 'string' ? result.output : '';
|
|
1094
|
+
const isError = result.isError === true || (output && output.startsWith('Error:'));
|
|
1095
|
+
events.push({
|
|
1096
|
+
type: isError ? 'error' : 'tool_result',
|
|
1097
|
+
agent: 'kimi',
|
|
1098
|
+
timestamp,
|
|
1099
|
+
tool: toolName,
|
|
1100
|
+
success: !isError,
|
|
1101
|
+
output: output.length > 500 ? output.slice(0, 497) + '...' : output,
|
|
1102
|
+
});
|
|
1103
|
+
if (callId) {
|
|
1104
|
+
toolCallMap.delete(callId);
|
|
1105
|
+
}
|
|
1106
|
+
}
|
|
1107
|
+
}
|
|
1108
|
+
else if (type === 'usage.record') {
|
|
1109
|
+
const usage = raw.usage || {};
|
|
1110
|
+
const inputTokens = usage.inputOther ?? usage.input_tokens;
|
|
1111
|
+
const outputTokens = usage.output ?? usage.output_tokens;
|
|
1112
|
+
if ((typeof inputTokens === 'number' && inputTokens >= 0) ||
|
|
1113
|
+
(typeof outputTokens === 'number' && outputTokens >= 0)) {
|
|
1114
|
+
events.push({
|
|
1115
|
+
type: 'usage',
|
|
1116
|
+
agent: 'kimi',
|
|
1117
|
+
timestamp,
|
|
1118
|
+
model: raw.model || usage.model,
|
|
1119
|
+
inputTokens: typeof inputTokens === 'number' ? inputTokens : undefined,
|
|
1120
|
+
outputTokens: typeof outputTokens === 'number' ? outputTokens : undefined,
|
|
1121
|
+
});
|
|
1122
|
+
}
|
|
1123
|
+
}
|
|
1124
|
+
}
|
|
1125
|
+
return events;
|
|
1126
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-agent adapter for sync. Each supported agent declares where its
|
|
3
|
+
* transcripts live and how to derive a session id and storage-relative key
|
|
4
|
+
* from a file path. The merge (crdt.ts) and transport (r2.ts) are fully
|
|
5
|
+
* agent-agnostic — adding a new agent is just another entry in SYNC_AGENTS.
|
|
6
|
+
*
|
|
7
|
+
* Mirror layout: synced-in transcripts land under
|
|
8
|
+
* ~/.agents/.history/backups/<agent>/<machine>/<subdir>/<relKey>
|
|
9
|
+
* which is already a scan root (getAgentSessionDirs scans backups/<agent>/<ts>),
|
|
10
|
+
* so the existing incremental scanner indexes them with no changes. Because the
|
|
11
|
+
* scanner dedups by session id with the live home scanned first, a session that
|
|
12
|
+
* also exists locally always wins — the mirror only ever fills in sessions
|
|
13
|
+
* originated on other machines.
|
|
14
|
+
*/
|
|
15
|
+
export interface LocalTranscript {
|
|
16
|
+
/** Absolute path on this machine. */
|
|
17
|
+
absPath: string;
|
|
18
|
+
/** Globally-unique session id (the grouping key across machines). */
|
|
19
|
+
sessionId: string;
|
|
20
|
+
/** Path relative to the agent's subdir root — preserved in the mirror layout. */
|
|
21
|
+
relKey: string;
|
|
22
|
+
}
|
|
23
|
+
export interface SyncAgentSpec {
|
|
24
|
+
id: string;
|
|
25
|
+
/** Config subdir under the agent home that holds transcripts. */
|
|
26
|
+
subdir: string;
|
|
27
|
+
/** Derive the session id from a storage-relative key. */
|
|
28
|
+
sessionIdFromRelKey(relKey: string): string;
|
|
29
|
+
}
|
|
30
|
+
export declare const SYNC_AGENTS: SyncAgentSpec[];
|
|
31
|
+
/**
|
|
32
|
+
* List this machine's own transcript files for an agent, EXCLUDING the sync
|
|
33
|
+
* mirror (we never re-upload another machine's files under our prefix). Dedups
|
|
34
|
+
* by session id so a session present in multiple version homes is uploaded once.
|
|
35
|
+
*/
|
|
36
|
+
export declare function listLocalTranscripts(spec: SyncAgentSpec): LocalTranscript[];
|
|
37
|
+
/** Session ids this machine holds locally (live home), used to skip mirror writes. */
|
|
38
|
+
export declare function localSessionIds(spec: SyncAgentSpec): Set<string>;
|
|
39
|
+
/** Absolute mirror path for a remote machine's transcript — lands in a scan root. */
|
|
40
|
+
export declare function mirrorPath(spec: SyncAgentSpec, machine: string, relKey: string): string;
|
|
41
|
+
/** R2 object key for a transcript: sessions/<machine>/<agent>/<sessionId>.jsonl */
|
|
42
|
+
export declare function objectKey(machine: string, agentId: string, sessionId: string): string;
|
|
43
|
+
/** R2 object key for a machine's manifest. */
|
|
44
|
+
export declare function manifestKey(machine: string): string;
|
|
45
|
+
/** Prefix under which all machine manifests live (for discovery). */
|
|
46
|
+
export declare const SESSIONS_PREFIX = "sessions/";
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-agent adapter for sync. Each supported agent declares where its
|
|
3
|
+
* transcripts live and how to derive a session id and storage-relative key
|
|
4
|
+
* from a file path. The merge (crdt.ts) and transport (r2.ts) are fully
|
|
5
|
+
* agent-agnostic — adding a new agent is just another entry in SYNC_AGENTS.
|
|
6
|
+
*
|
|
7
|
+
* Mirror layout: synced-in transcripts land under
|
|
8
|
+
* ~/.agents/.history/backups/<agent>/<machine>/<subdir>/<relKey>
|
|
9
|
+
* which is already a scan root (getAgentSessionDirs scans backups/<agent>/<ts>),
|
|
10
|
+
* so the existing incremental scanner indexes them with no changes. Because the
|
|
11
|
+
* scanner dedups by session id with the live home scanned first, a session that
|
|
12
|
+
* also exists locally always wins — the mirror only ever fills in sessions
|
|
13
|
+
* originated on other machines.
|
|
14
|
+
*/
|
|
15
|
+
import * as fs from 'fs';
|
|
16
|
+
import * as path from 'path';
|
|
17
|
+
import { getHistoryDir } from '../../state.js';
|
|
18
|
+
import { getAgentSessionDirs } from '../discover.js';
|
|
19
|
+
import { walkForFiles } from '../../fs-walk.js';
|
|
20
|
+
const UUID_RE = /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/i;
|
|
21
|
+
export const SYNC_AGENTS = [
|
|
22
|
+
{
|
|
23
|
+
id: 'claude',
|
|
24
|
+
subdir: 'projects',
|
|
25
|
+
// Claude transcripts are <projectDir>/<sessionId>.jsonl.
|
|
26
|
+
sessionIdFromRelKey: rel => path.basename(rel).replace(/\.jsonl$/, ''),
|
|
27
|
+
},
|
|
28
|
+
{
|
|
29
|
+
id: 'codex',
|
|
30
|
+
subdir: 'sessions',
|
|
31
|
+
// Codex transcripts are rollout-<ts>-<uuid>.jsonl under date dirs; the uuid
|
|
32
|
+
// is the session id (matches session_meta.payload.id).
|
|
33
|
+
sessionIdFromRelKey: rel => path.basename(rel).match(UUID_RE)?.[0] ?? rel,
|
|
34
|
+
},
|
|
35
|
+
];
|
|
36
|
+
let cachedMirrorRoot = null;
|
|
37
|
+
function mirrorRootReal() {
|
|
38
|
+
if (cachedMirrorRoot)
|
|
39
|
+
return cachedMirrorRoot;
|
|
40
|
+
const root = path.join(getHistoryDir(), 'backups');
|
|
41
|
+
cachedMirrorRoot = safeReal(root);
|
|
42
|
+
return cachedMirrorRoot;
|
|
43
|
+
}
|
|
44
|
+
function safeReal(p) {
|
|
45
|
+
try {
|
|
46
|
+
return fs.realpathSync(p);
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
return path.resolve(p);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* List this machine's own transcript files for an agent, EXCLUDING the sync
|
|
54
|
+
* mirror (we never re-upload another machine's files under our prefix). Dedups
|
|
55
|
+
* by session id so a session present in multiple version homes is uploaded once.
|
|
56
|
+
*/
|
|
57
|
+
export function listLocalTranscripts(spec) {
|
|
58
|
+
const mirror = mirrorRootReal();
|
|
59
|
+
const out = [];
|
|
60
|
+
const seen = new Set();
|
|
61
|
+
for (const dir of getAgentSessionDirs(spec.id, spec.subdir)) {
|
|
62
|
+
if (safeReal(dir).startsWith(mirror))
|
|
63
|
+
continue; // skip synced-in mirror dirs
|
|
64
|
+
for (const abs of walkForFiles(dir, '.jsonl', 100_000)) {
|
|
65
|
+
const relKey = path.relative(dir, abs);
|
|
66
|
+
if (!relKey || relKey.startsWith('..'))
|
|
67
|
+
continue;
|
|
68
|
+
const sessionId = spec.sessionIdFromRelKey(relKey);
|
|
69
|
+
if (seen.has(sessionId))
|
|
70
|
+
continue;
|
|
71
|
+
seen.add(sessionId);
|
|
72
|
+
out.push({ absPath: abs, sessionId, relKey });
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
return out;
|
|
76
|
+
}
|
|
77
|
+
/** Session ids this machine holds locally (live home), used to skip mirror writes. */
|
|
78
|
+
export function localSessionIds(spec) {
|
|
79
|
+
return new Set(listLocalTranscripts(spec).map(t => t.sessionId));
|
|
80
|
+
}
|
|
81
|
+
/** Absolute mirror path for a remote machine's transcript — lands in a scan root. */
|
|
82
|
+
export function mirrorPath(spec, machine, relKey) {
|
|
83
|
+
return path.join(getHistoryDir(), 'backups', spec.id, machine, spec.subdir, relKey);
|
|
84
|
+
}
|
|
85
|
+
/** R2 object key for a transcript: sessions/<machine>/<agent>/<sessionId>.jsonl */
|
|
86
|
+
export function objectKey(machine, agentId, sessionId) {
|
|
87
|
+
return `sessions/${machine}/${agentId}/${sessionId}.jsonl`;
|
|
88
|
+
}
|
|
89
|
+
/** R2 object key for a machine's manifest. */
|
|
90
|
+
export function manifestKey(machine) {
|
|
91
|
+
return `sessions/${machine}/manifest.json`;
|
|
92
|
+
}
|
|
93
|
+
/** Prefix under which all machine manifests live (for discovery). */
|
|
94
|
+
export const SESSIONS_PREFIX = 'sessions/';
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Configuration for cross-machine session sync: R2 credentials and this
|
|
3
|
+
* machine's stable identity. Credentials come from the `r2.backups` secrets
|
|
4
|
+
* bundle (OS keychain on macOS, libsecret on Linux) — never from env or disk.
|
|
5
|
+
*/
|
|
6
|
+
/** Secrets bundle holding the R2 credentials. */
|
|
7
|
+
export declare const SYNC_BUNDLE = "r2.backups";
|
|
8
|
+
export interface R2Config {
|
|
9
|
+
accountId: string;
|
|
10
|
+
bucket: string;
|
|
11
|
+
accessKeyId: string;
|
|
12
|
+
secretAccessKey: string;
|
|
13
|
+
/** S3-compatible endpoint for the account (no bucket, no trailing slash). */
|
|
14
|
+
endpoint: string;
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Resolve R2 credentials from the `r2.backups` bundle. Throws a clear,
|
|
18
|
+
* actionable error if the bundle or any key is missing — sync cannot proceed
|
|
19
|
+
* without real credentials (no silent fallback).
|
|
20
|
+
*/
|
|
21
|
+
export declare function loadR2Config(): R2Config;
|
|
22
|
+
/** True when the sync bundle exists and looks resolvable, without throwing. */
|
|
23
|
+
export declare function isSyncConfigured(): boolean;
|
|
24
|
+
/**
|
|
25
|
+
* This machine's stable, human-readable id, used as its R2 prefix and mirror
|
|
26
|
+
* directory name. Tailnet hostnames (zion, yosemite-s0, mac-mini) are already
|
|
27
|
+
* unique and readable; we lowercase and strip any domain suffix. Overridable
|
|
28
|
+
* via AGENTS_SYNC_MACHINE_ID for tests and unusual setups.
|
|
29
|
+
*/
|
|
30
|
+
export declare function machineId(): string;
|