@agentoryhq/adapter-claude-code 0.1.1 → 0.1.3
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/dist/align.d.ts +2 -0
- package/dist/align.js +2 -0
- package/dist/asset-index.d.ts +15 -0
- package/dist/asset-index.js +221 -0
- package/dist/asset-index.test.d.ts +1 -0
- package/dist/asset-index.test.js +261 -0
- package/dist/discover.js +3 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +11 -1
- package/dist/invoke.d.ts +32 -0
- package/dist/invoke.js +142 -0
- package/dist/invoke.test.d.ts +1 -0
- package/dist/invoke.test.js +145 -0
- package/dist/transcript.d.ts +31 -0
- package/dist/transcript.js +173 -0
- package/package.json +5 -4
package/dist/align.d.ts
CHANGED
|
@@ -3,6 +3,8 @@ import type { ApplyAlignOpts, ApplyAlignResult } from "@agentoryhq/runtime-contr
|
|
|
3
3
|
* Write confirmed align patch to Claude Code project files.
|
|
4
4
|
* - MCP → project root `.mcp.json`
|
|
5
5
|
* - Skills name list → `.claude/agentory-skills.json` (tracking; does not install skill bodies)
|
|
6
|
+
* - patch.rules 显式忽略(rules-desired-config Q1 裁决:一期规则载体只有 cursor,
|
|
7
|
+
* claude-code 诚实提示暂不支持;API 侧对 claude-code 恒下发空 rules patch,双保险)
|
|
6
8
|
* Snapshots prior `.mcp.json` under `.claude/agentory-snapshots/`.
|
|
7
9
|
*/
|
|
8
10
|
export declare function applyAlignPatch(opts: ApplyAlignOpts): ApplyAlignResult;
|
package/dist/align.js
CHANGED
|
@@ -4,6 +4,8 @@ import path from "node:path";
|
|
|
4
4
|
* Write confirmed align patch to Claude Code project files.
|
|
5
5
|
* - MCP → project root `.mcp.json`
|
|
6
6
|
* - Skills name list → `.claude/agentory-skills.json` (tracking; does not install skill bodies)
|
|
7
|
+
* - patch.rules 显式忽略(rules-desired-config Q1 裁决:一期规则载体只有 cursor,
|
|
8
|
+
* claude-code 诚实提示暂不支持;API 侧对 claude-code 恒下发空 rules patch,双保险)
|
|
7
9
|
* Snapshots prior `.mcp.json` under `.claude/agentory-snapshots/`.
|
|
8
10
|
*/
|
|
9
11
|
export function applyAlignPatch(opts) {
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { AdapterContext, AssetIndexResult } from "@agentoryhq/runtime-contract";
|
|
2
|
+
/**
|
|
3
|
+
* Read only enough of the session jsonl (16KB) to derive a short title —
|
|
4
|
+
* never the full body. Falls back to "" when no human row is in range.
|
|
5
|
+
*/
|
|
6
|
+
export declare function titleFromClaudeSessionJsonl(file: string): string;
|
|
7
|
+
/** cwd field on rows is the authoritative projectPath (first row wins, 16KB-peek). */
|
|
8
|
+
export declare function projectPathFromClaudeSessionJsonl(file: string): string | undefined;
|
|
9
|
+
/**
|
|
10
|
+
* Claude Code asset index (metadata only).
|
|
11
|
+
* - Sessions: ~/.claude/projects/<slug>/<uuid>.jsonl (top-level .jsonl only;
|
|
12
|
+
* <uuid>/ subdirs hold subagent/tool data and are excluded).
|
|
13
|
+
* - Memory: ~/.claude/projects/<slug>/memory/*.md (kind=memory, Q1 随行).
|
|
14
|
+
*/
|
|
15
|
+
export declare function indexClaudeSessions(ctx: AdapterContext): AssetIndexResult;
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
const TITLE_MAX = 80;
|
|
4
|
+
const PEEK_BYTES = 16 * 1024;
|
|
5
|
+
function isHumanOrigin(row) {
|
|
6
|
+
const origin = row.origin;
|
|
7
|
+
return !!origin && typeof origin === "object" &&
|
|
8
|
+
origin.kind === "human";
|
|
9
|
+
}
|
|
10
|
+
/** Human gate: user row + non-tool_result content shape + origin.kind==="human". */
|
|
11
|
+
function humanTextFromUserRow(row) {
|
|
12
|
+
if (row.type !== "user" || row.isSidechain === true)
|
|
13
|
+
return "";
|
|
14
|
+
if (!isHumanOrigin(row))
|
|
15
|
+
return "";
|
|
16
|
+
const msg = row.message;
|
|
17
|
+
if (!msg || typeof msg !== "object")
|
|
18
|
+
return "";
|
|
19
|
+
const content = msg.content;
|
|
20
|
+
// tool_result arrays are tool echoes wearing a "user" type — reject.
|
|
21
|
+
if (Array.isArray(content) &&
|
|
22
|
+
content.some((b) => b && typeof b === "object" && b.type === "tool_result")) {
|
|
23
|
+
return "";
|
|
24
|
+
}
|
|
25
|
+
if (typeof content === "string")
|
|
26
|
+
return content;
|
|
27
|
+
if (Array.isArray(content)) {
|
|
28
|
+
const parts = [];
|
|
29
|
+
for (const b of content) {
|
|
30
|
+
if (typeof b === "string")
|
|
31
|
+
parts.push(b);
|
|
32
|
+
else if (b && typeof b === "object" && typeof b.text === "string") {
|
|
33
|
+
parts.push(b.text);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
return parts.join(" ");
|
|
37
|
+
}
|
|
38
|
+
return "";
|
|
39
|
+
}
|
|
40
|
+
function cleanTitleText(text) {
|
|
41
|
+
return text.replace(/\s+/g, " ").trim();
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Read only enough of the session jsonl (16KB) to derive a short title —
|
|
45
|
+
* never the full body. Falls back to "" when no human row is in range.
|
|
46
|
+
*/
|
|
47
|
+
export function titleFromClaudeSessionJsonl(file) {
|
|
48
|
+
try {
|
|
49
|
+
const fd = fs.openSync(file, "r");
|
|
50
|
+
try {
|
|
51
|
+
const buf = Buffer.alloc(PEEK_BYTES);
|
|
52
|
+
const n = fs.readSync(fd, buf, 0, buf.length, 0);
|
|
53
|
+
const chunk = buf.slice(0, n).toString("utf8");
|
|
54
|
+
for (const line of chunk.split("\n")) {
|
|
55
|
+
if (!line.trim())
|
|
56
|
+
continue;
|
|
57
|
+
let o;
|
|
58
|
+
try {
|
|
59
|
+
o = JSON.parse(line);
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
if (!o || typeof o !== "object")
|
|
65
|
+
continue;
|
|
66
|
+
const text = cleanTitleText(humanTextFromUserRow(o));
|
|
67
|
+
if (!text)
|
|
68
|
+
continue;
|
|
69
|
+
return text.length > TITLE_MAX ? `${text.slice(0, TITLE_MAX)}…` : text;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
finally {
|
|
73
|
+
fs.closeSync(fd);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
catch {
|
|
77
|
+
/* ignore unreadable file */
|
|
78
|
+
}
|
|
79
|
+
return "";
|
|
80
|
+
}
|
|
81
|
+
/** cwd field on rows is the authoritative projectPath (first row wins, 16KB-peek). */
|
|
82
|
+
export function projectPathFromClaudeSessionJsonl(file) {
|
|
83
|
+
try {
|
|
84
|
+
const fd = fs.openSync(file, "r");
|
|
85
|
+
try {
|
|
86
|
+
const buf = Buffer.alloc(PEEK_BYTES);
|
|
87
|
+
const n = fs.readSync(fd, buf, 0, buf.length, 0);
|
|
88
|
+
const chunk = buf.slice(0, n).toString("utf8");
|
|
89
|
+
for (const line of chunk.split("\n")) {
|
|
90
|
+
if (!line.trim())
|
|
91
|
+
continue;
|
|
92
|
+
let o;
|
|
93
|
+
try {
|
|
94
|
+
o = JSON.parse(line);
|
|
95
|
+
}
|
|
96
|
+
catch {
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
if (!o || typeof o !== "object")
|
|
100
|
+
continue;
|
|
101
|
+
const cwd = o.cwd;
|
|
102
|
+
if (typeof cwd === "string" && cwd.trim())
|
|
103
|
+
return cwd;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
finally {
|
|
107
|
+
fs.closeSync(fd);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
catch {
|
|
111
|
+
/* ignore unreadable file */
|
|
112
|
+
}
|
|
113
|
+
return undefined;
|
|
114
|
+
}
|
|
115
|
+
function mtimeIso(file) {
|
|
116
|
+
try {
|
|
117
|
+
return fs.statSync(file).mtime.toISOString();
|
|
118
|
+
}
|
|
119
|
+
catch {
|
|
120
|
+
return new Date(0).toISOString();
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
/**
|
|
124
|
+
* Claude Code asset index (metadata only).
|
|
125
|
+
* - Sessions: ~/.claude/projects/<slug>/<uuid>.jsonl (top-level .jsonl only;
|
|
126
|
+
* <uuid>/ subdirs hold subagent/tool data and are excluded).
|
|
127
|
+
* - Memory: ~/.claude/projects/<slug>/memory/*.md (kind=memory, Q1 随行).
|
|
128
|
+
*/
|
|
129
|
+
export function indexClaudeSessions(ctx) {
|
|
130
|
+
const home = ctx.homeDir;
|
|
131
|
+
const projectsRoot = path.join(home, ".claude", "projects");
|
|
132
|
+
const sessions = [];
|
|
133
|
+
const memories = [];
|
|
134
|
+
let slugs = [];
|
|
135
|
+
try {
|
|
136
|
+
slugs = fs.readdirSync(projectsRoot);
|
|
137
|
+
}
|
|
138
|
+
catch {
|
|
139
|
+
return { sessions, memories };
|
|
140
|
+
}
|
|
141
|
+
for (const slug of slugs) {
|
|
142
|
+
const projectDir = path.join(projectsRoot, slug);
|
|
143
|
+
try {
|
|
144
|
+
if (!fs.statSync(projectDir).isDirectory())
|
|
145
|
+
continue;
|
|
146
|
+
}
|
|
147
|
+
catch {
|
|
148
|
+
continue;
|
|
149
|
+
}
|
|
150
|
+
let names = [];
|
|
151
|
+
try {
|
|
152
|
+
names = fs.readdirSync(projectDir);
|
|
153
|
+
}
|
|
154
|
+
catch {
|
|
155
|
+
continue;
|
|
156
|
+
}
|
|
157
|
+
// Prefer cwd from the first indexed session in this slug as the memory projectPath
|
|
158
|
+
// (memory/*.md has no cwd of its own; never invent a fake <name>.jsonl path).
|
|
159
|
+
let slugProjectPath;
|
|
160
|
+
for (const name of names) {
|
|
161
|
+
// Top-level .jsonl files only — <uuid>/ subdirs and stray files skipped.
|
|
162
|
+
if (!name.endsWith(".jsonl"))
|
|
163
|
+
continue;
|
|
164
|
+
const file = path.join(projectDir, name);
|
|
165
|
+
try {
|
|
166
|
+
if (!fs.statSync(file).isFile())
|
|
167
|
+
continue;
|
|
168
|
+
}
|
|
169
|
+
catch {
|
|
170
|
+
continue;
|
|
171
|
+
}
|
|
172
|
+
const title = titleFromClaudeSessionJsonl(file);
|
|
173
|
+
if (!title)
|
|
174
|
+
continue; // no human row in peek range — not an indexable conversation
|
|
175
|
+
const projectPath = projectPathFromClaudeSessionJsonl(file);
|
|
176
|
+
if (!slugProjectPath && projectPath)
|
|
177
|
+
slugProjectPath = projectPath;
|
|
178
|
+
sessions.push({
|
|
179
|
+
sourceKey: `claude-session:${file}`,
|
|
180
|
+
displayName: title,
|
|
181
|
+
projectPath,
|
|
182
|
+
updatedAt: mtimeIso(file),
|
|
183
|
+
runtime: "claude-code",
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
// Memory dimension (Q1 随行): <slug>/memory/*.md, path label keeps the slug visible.
|
|
187
|
+
const memoryDir = path.join(projectDir, "memory");
|
|
188
|
+
if (fs.existsSync(memoryDir)) {
|
|
189
|
+
let memNames = [];
|
|
190
|
+
try {
|
|
191
|
+
memNames = fs.readdirSync(memoryDir);
|
|
192
|
+
}
|
|
193
|
+
catch {
|
|
194
|
+
continue;
|
|
195
|
+
}
|
|
196
|
+
for (const name of memNames) {
|
|
197
|
+
if (!name.endsWith(".md"))
|
|
198
|
+
continue;
|
|
199
|
+
const file = path.join(memoryDir, name);
|
|
200
|
+
try {
|
|
201
|
+
if (!fs.statSync(file).isFile())
|
|
202
|
+
continue;
|
|
203
|
+
}
|
|
204
|
+
catch {
|
|
205
|
+
continue;
|
|
206
|
+
}
|
|
207
|
+
memories.push({
|
|
208
|
+
sourceKey: `claude-memory:${file}`,
|
|
209
|
+
kind: "memory",
|
|
210
|
+
pathLabel: `${slug}/memory/${name}`,
|
|
211
|
+
projectPath: slugProjectPath,
|
|
212
|
+
updatedAt: mtimeIso(file),
|
|
213
|
+
runtime: "claude-code",
|
|
214
|
+
});
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
sessions.sort((a, b) => (a.updatedAt < b.updatedAt ? 1 : a.updatedAt > b.updatedAt ? -1 : 0));
|
|
219
|
+
memories.sort((a, b) => (a.updatedAt < b.updatedAt ? 1 : a.updatedAt > b.updatedAt ? -1 : 0));
|
|
220
|
+
return { sessions, memories };
|
|
221
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { test } from "node:test";
|
|
6
|
+
import { TRANSCRIPT_MESSAGE_MAX_CHARS, TRANSCRIPT_WINDOW_MESSAGES, captureTranscriptSnapshot, indexClaudeSessions, maskSecrets, projectPathFromClaudeSessionJsonl, titleFromClaudeSessionJsonl, transcriptFileForSourceKey, } from "./index.js";
|
|
7
|
+
function makeHome() {
|
|
8
|
+
return fs.mkdtempSync(path.join(os.tmpdir(), "agy-claude-idx-"));
|
|
9
|
+
}
|
|
10
|
+
/** Real-shape claude rows (verified schema; bodies are fixture-only). */
|
|
11
|
+
function row(partial) {
|
|
12
|
+
return {
|
|
13
|
+
cwd: "/Users/demo/work/app",
|
|
14
|
+
isSidechain: false,
|
|
15
|
+
timestamp: "2026-10-30T10:00:00.000Z",
|
|
16
|
+
version: "2.1.0",
|
|
17
|
+
...partial,
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
function humanRow(text) {
|
|
21
|
+
return row({
|
|
22
|
+
type: "user",
|
|
23
|
+
origin: { kind: "human" },
|
|
24
|
+
message: { role: "user", content: text },
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
function writeSession(home, slug, file, rows) {
|
|
28
|
+
const target = path.join(home, ".claude", "projects", slug, file);
|
|
29
|
+
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
30
|
+
fs.writeFileSync(target, rows.map((r) => JSON.stringify(r)).join("\n"));
|
|
31
|
+
return target;
|
|
32
|
+
}
|
|
33
|
+
test("indexClaudeSessions discovers sessions + memory, excludes subdirs and non-dialog files (AC-3)", () => {
|
|
34
|
+
const home = makeHome();
|
|
35
|
+
try {
|
|
36
|
+
const jsonl = writeSession(home, "Users-demo-work-app", "11111111-2222-3333-4444-555555555555.jsonl", [
|
|
37
|
+
{ type: "mode", mode: "default" },
|
|
38
|
+
{ type: "permission-mode", permissionMode: "default" },
|
|
39
|
+
{ type: "system", subtype: "init", content: "system noise" },
|
|
40
|
+
humanRow("Fix the flaky test in ci"),
|
|
41
|
+
row({
|
|
42
|
+
type: "user",
|
|
43
|
+
message: { role: "user", content: [{ type: "tool_result", content: "tool echo" }] },
|
|
44
|
+
}),
|
|
45
|
+
row({
|
|
46
|
+
type: "assistant",
|
|
47
|
+
message: { role: "assistant", content: [{ type: "text", text: "On it" }] },
|
|
48
|
+
}),
|
|
49
|
+
]);
|
|
50
|
+
// <uuid>/ subdir (subagent data) — must NOT be indexed even if it holds .jsonl-like files
|
|
51
|
+
const sub = path.join(home, ".claude", "projects", "Users-demo-work-app", "11111111-2222-3333-4444-555555555555", "sub.jsonl");
|
|
52
|
+
fs.mkdirSync(path.dirname(sub), { recursive: true });
|
|
53
|
+
fs.writeFileSync(sub, "garbage");
|
|
54
|
+
// non-jsonl stray file
|
|
55
|
+
fs.writeFileSync(path.join(home, ".claude", "projects", "Users-demo-work-app", "notes.txt"), "x");
|
|
56
|
+
// memory dimension (Q1 随行)
|
|
57
|
+
const memDir = path.join(home, ".claude", "projects", "Users-demo-work-app", "memory");
|
|
58
|
+
fs.mkdirSync(memDir, { recursive: true });
|
|
59
|
+
fs.writeFileSync(path.join(memDir, "MEMORY.md"), "# index\n");
|
|
60
|
+
const out = indexClaudeSessions({ homeDir: home, cwd: home });
|
|
61
|
+
assert.equal(out.sessions.length, 1);
|
|
62
|
+
const s = out.sessions[0];
|
|
63
|
+
assert.equal(s.sourceKey, `claude-session:${jsonl}`);
|
|
64
|
+
assert.equal(s.runtime, "claude-code");
|
|
65
|
+
// title = first human row text, NOT the tool_result echo or system rows
|
|
66
|
+
assert.equal(s.displayName, "Fix the flaky test in ci");
|
|
67
|
+
// cwd field is the authoritative projectPath
|
|
68
|
+
assert.equal(s.projectPath, "/Users/demo/work/app");
|
|
69
|
+
assert.ok(s.updatedAt);
|
|
70
|
+
assert.equal(out.memories.length, 1);
|
|
71
|
+
const m = out.memories[0];
|
|
72
|
+
assert.equal(m.kind, "memory");
|
|
73
|
+
assert.equal(m.sourceKey, `claude-memory:${path.join(memDir, "MEMORY.md")}`);
|
|
74
|
+
assert.equal(m.runtime, "claude-code");
|
|
75
|
+
assert.match(m.pathLabel, /Users-demo-work-app\/memory\/MEMORY\.md/);
|
|
76
|
+
// memory projectPath inherits cwd from a sibling session in the same slug
|
|
77
|
+
assert.equal(m.projectPath, "/Users/demo/work/app");
|
|
78
|
+
}
|
|
79
|
+
finally {
|
|
80
|
+
fs.rmSync(home, { recursive: true, force: true });
|
|
81
|
+
}
|
|
82
|
+
});
|
|
83
|
+
test("indexClaudeSessions excludes sidechain rows from the title (AC-3)", () => {
|
|
84
|
+
const home = makeHome();
|
|
85
|
+
try {
|
|
86
|
+
const jsonl = writeSession(home, "Users-demo-work-app", "99999999-8888-7777-6666-555555555555.jsonl", [
|
|
87
|
+
// sidechain human row comes first in file order — must be skipped for title
|
|
88
|
+
row({
|
|
89
|
+
type: "user",
|
|
90
|
+
isSidechain: true,
|
|
91
|
+
origin: { kind: "human" },
|
|
92
|
+
message: { role: "user", content: "subagent internal prompt" },
|
|
93
|
+
}),
|
|
94
|
+
humanRow("Main thread request"),
|
|
95
|
+
]);
|
|
96
|
+
const title = titleFromClaudeSessionJsonl(jsonl);
|
|
97
|
+
assert.equal(title, "Main thread request");
|
|
98
|
+
const out = indexClaudeSessions({ homeDir: home, cwd: home });
|
|
99
|
+
assert.equal(out.sessions.length, 1);
|
|
100
|
+
assert.equal(out.sessions[0].displayName, "Main thread request");
|
|
101
|
+
}
|
|
102
|
+
finally {
|
|
103
|
+
fs.rmSync(home, { recursive: true, force: true });
|
|
104
|
+
}
|
|
105
|
+
});
|
|
106
|
+
test("titleFromClaudeSessionJsonl truncates to 80 chars (AC-3)", () => {
|
|
107
|
+
const home = makeHome();
|
|
108
|
+
try {
|
|
109
|
+
const jsonl = writeSession(home, "Users-demo-work-app", "aaaa1111-bbbb-cccc-dddd-eeeeeeeeeeee.jsonl", [
|
|
110
|
+
humanRow("y".repeat(200)),
|
|
111
|
+
]);
|
|
112
|
+
const title = titleFromClaudeSessionJsonl(jsonl);
|
|
113
|
+
assert.ok(title.length <= 80 + 1); // 80 chars + …
|
|
114
|
+
assert.ok(title.endsWith("…"));
|
|
115
|
+
}
|
|
116
|
+
finally {
|
|
117
|
+
fs.rmSync(home, { recursive: true, force: true });
|
|
118
|
+
}
|
|
119
|
+
});
|
|
120
|
+
test("indexClaudeSessions skips jsonl without any human row (not an indexable conversation)", () => {
|
|
121
|
+
const home = makeHome();
|
|
122
|
+
try {
|
|
123
|
+
writeSession(home, "Users-demo-work-app", "00000000-0000-0000-0000-000000000000.jsonl", [
|
|
124
|
+
{ type: "mode", mode: "default" },
|
|
125
|
+
row({ type: "assistant", message: { role: "assistant", content: [{ type: "text", text: "orphan reply" }] } }),
|
|
126
|
+
]);
|
|
127
|
+
const out = indexClaudeSessions({ homeDir: home, cwd: home });
|
|
128
|
+
assert.equal(out.sessions.length, 0);
|
|
129
|
+
}
|
|
130
|
+
finally {
|
|
131
|
+
fs.rmSync(home, { recursive: true, force: true });
|
|
132
|
+
}
|
|
133
|
+
});
|
|
134
|
+
test("projectPathFromClaudeSessionJsonl reads cwd from first row", () => {
|
|
135
|
+
const home = makeHome();
|
|
136
|
+
try {
|
|
137
|
+
const jsonl = writeSession(home, "Users-demo-work-app", "12345678-1234-1234-1234-123456789012.jsonl", [
|
|
138
|
+
row({ type: "system", cwd: "/Users/demo/work/app" }),
|
|
139
|
+
humanRow("hi"),
|
|
140
|
+
]);
|
|
141
|
+
assert.equal(projectPathFromClaudeSessionJsonl(jsonl), "/Users/demo/work/app");
|
|
142
|
+
}
|
|
143
|
+
finally {
|
|
144
|
+
fs.rmSync(home, { recursive: true, force: true });
|
|
145
|
+
}
|
|
146
|
+
});
|
|
147
|
+
test("maskSecrets masks common token shapes (AC-4)", () => {
|
|
148
|
+
const cases = [
|
|
149
|
+
["sk-abcdef0123456789abcdef", "sk-••••••••"],
|
|
150
|
+
["ghp_0123456789abcdefghijklmnopqrstuvwxyz", "ghp_••••••••"],
|
|
151
|
+
["xoxb-0123456789abcdefGHI", "xox•-••••••••"],
|
|
152
|
+
];
|
|
153
|
+
for (const [raw, masked] of cases) {
|
|
154
|
+
assert.equal(maskSecrets(raw).includes(masked), true);
|
|
155
|
+
assert.equal(maskSecrets(raw).includes(raw), false);
|
|
156
|
+
}
|
|
157
|
+
const kv = maskSecrets("deploy with password=hunter2-secret now");
|
|
158
|
+
assert.equal(kv.includes("hunter2"), false);
|
|
159
|
+
assert.equal(maskSecrets("fix the login bug please"), "fix the login bug please");
|
|
160
|
+
});
|
|
161
|
+
test("captureTranscriptSnapshot: human gate, sidechain/tool_result exclusion, masking (AC-4)", () => {
|
|
162
|
+
const home = makeHome();
|
|
163
|
+
try {
|
|
164
|
+
const long = "x".repeat(TRANSCRIPT_MESSAGE_MAX_CHARS + 500);
|
|
165
|
+
const jsonl = writeSession(home, "Users-demo-work-app", "abcdefab-cdef-abcd-efab-cdefabcdefab.jsonl", [
|
|
166
|
+
{ type: "mode", mode: "default" },
|
|
167
|
+
{ type: "permission-mode", permissionMode: "acceptEdits" },
|
|
168
|
+
{ type: "file-history-snapshot", snapshot: "x" },
|
|
169
|
+
{ type: "attachment", attachment: { path: "x" } },
|
|
170
|
+
humanRow("Fix login with sk-abcdef0123456789abcdef"),
|
|
171
|
+
// tool_result row (type user) — NOT human input, excluded
|
|
172
|
+
row({
|
|
173
|
+
type: "user",
|
|
174
|
+
message: { role: "user", content: [{ type: "tool_result", content: "tool echo body" }] },
|
|
175
|
+
}),
|
|
176
|
+
// user row without human origin — excluded
|
|
177
|
+
row({
|
|
178
|
+
type: "user",
|
|
179
|
+
message: { role: "user", content: "no-origin text" },
|
|
180
|
+
}),
|
|
181
|
+
// sidechain assistant — excluded
|
|
182
|
+
row({
|
|
183
|
+
type: "assistant",
|
|
184
|
+
isSidechain: true,
|
|
185
|
+
message: { role: "assistant", content: [{ type: "text", text: "subagent text" }] },
|
|
186
|
+
}),
|
|
187
|
+
row({
|
|
188
|
+
type: "assistant",
|
|
189
|
+
message: {
|
|
190
|
+
role: "assistant",
|
|
191
|
+
content: [
|
|
192
|
+
{ type: "thinking", thinking: "internal" },
|
|
193
|
+
{ type: "text", text: long },
|
|
194
|
+
],
|
|
195
|
+
},
|
|
196
|
+
}),
|
|
197
|
+
"not-json",
|
|
198
|
+
]);
|
|
199
|
+
const snap = captureTranscriptSnapshot(jsonl, new Date("2026-10-30T12:00:00.000Z"));
|
|
200
|
+
assert.ok(snap);
|
|
201
|
+
assert.equal(snap.sourceKey, `claude-session:${jsonl}`);
|
|
202
|
+
assert.equal(snap.messages.length, 2);
|
|
203
|
+
assert.equal(snap.messages[0].role, "user");
|
|
204
|
+
assert.equal(snap.messages[0].text.includes("sk-abcdef"), false);
|
|
205
|
+
assert.ok(snap.messages[0].text.includes("sk-••••••••"));
|
|
206
|
+
assert.equal(snap.messages[1].role, "assistant");
|
|
207
|
+
assert.ok(snap.messages[1].text.length <= TRANSCRIPT_MESSAGE_MAX_CHARS + 1);
|
|
208
|
+
assert.ok(snap.messages[1].text.endsWith("…"));
|
|
209
|
+
// tool echo / non-human / sidechain / non-dialog rows never appear
|
|
210
|
+
const joined = snap.messages.map((m) => m.text).join(" ");
|
|
211
|
+
assert.ok(!joined.includes("tool echo"));
|
|
212
|
+
assert.ok(!joined.includes("no-origin"));
|
|
213
|
+
assert.ok(!joined.includes("subagent"));
|
|
214
|
+
assert.equal(snap.truncated, false);
|
|
215
|
+
assert.equal(snap.capturedAt, "2026-10-30T12:00:00.000Z");
|
|
216
|
+
}
|
|
217
|
+
finally {
|
|
218
|
+
fs.rmSync(home, { recursive: true, force: true });
|
|
219
|
+
}
|
|
220
|
+
});
|
|
221
|
+
test("captureTranscriptSnapshot keeps near-end window + truncated flag (AC-4)", () => {
|
|
222
|
+
const home = makeHome();
|
|
223
|
+
try {
|
|
224
|
+
const rows = Array.from({ length: TRANSCRIPT_WINDOW_MESSAGES + 27 }, (_, i) => humanRow(`msg-${i}`));
|
|
225
|
+
const jsonl = writeSession(home, "p-slug", "ffffffff-eeee-dddd-cccc-bbbbbbbbbbbb.jsonl", rows);
|
|
226
|
+
const snap = captureTranscriptSnapshot(jsonl);
|
|
227
|
+
assert.ok(snap);
|
|
228
|
+
assert.equal(snap.messages.length, TRANSCRIPT_WINDOW_MESSAGES);
|
|
229
|
+
assert.equal(snap.truncated, true);
|
|
230
|
+
assert.ok(snap.windowTotal);
|
|
231
|
+
assert.match(snap.windowTotal, new RegExp(`${TRANSCRIPT_WINDOW_MESSAGES} / ${rows.length}`));
|
|
232
|
+
assert.equal(snap.messages[0].text, `msg-${rows.length - TRANSCRIPT_WINDOW_MESSAGES}`);
|
|
233
|
+
}
|
|
234
|
+
finally {
|
|
235
|
+
fs.rmSync(home, { recursive: true, force: true });
|
|
236
|
+
}
|
|
237
|
+
});
|
|
238
|
+
test("captureTranscriptSnapshot missing file → undefined (AC-4)", () => {
|
|
239
|
+
const home = makeHome();
|
|
240
|
+
try {
|
|
241
|
+
assert.equal(captureTranscriptSnapshot(path.join(home, "nope.jsonl")), undefined);
|
|
242
|
+
}
|
|
243
|
+
finally {
|
|
244
|
+
fs.rmSync(home, { recursive: true, force: true });
|
|
245
|
+
}
|
|
246
|
+
});
|
|
247
|
+
test("transcriptFileForSourceKey enforces ~/.claude/projects containment (invariants §4, AC-4)", () => {
|
|
248
|
+
const home = makeHome();
|
|
249
|
+
try {
|
|
250
|
+
const inside = path.join(home, ".claude", "projects", "slug", "session.jsonl");
|
|
251
|
+
assert.equal(transcriptFileForSourceKey(`claude-session:${inside}`, home), inside);
|
|
252
|
+
const outside = path.join(home, ".ssh", "id_ed25519");
|
|
253
|
+
assert.equal(transcriptFileForSourceKey(`claude-session:${outside}`, home), undefined);
|
|
254
|
+
// cursor-prefixed keys are not ours — must not resolve
|
|
255
|
+
assert.equal(transcriptFileForSourceKey(`cursor-transcript:${inside}`, home), undefined);
|
|
256
|
+
assert.equal(transcriptFileForSourceKey("claude-session:", home), undefined);
|
|
257
|
+
}
|
|
258
|
+
finally {
|
|
259
|
+
fs.rmSync(home, { recursive: true, force: true });
|
|
260
|
+
}
|
|
261
|
+
});
|
package/dist/discover.js
CHANGED
|
@@ -83,5 +83,8 @@ export function discoverClaudeCodeConfig(ctx) {
|
|
|
83
83
|
displayName: `Claude Code · ${path.basename(ctx.cwd) || "workspace"}`,
|
|
84
84
|
mcp,
|
|
85
85
|
skills: [...skillNames],
|
|
86
|
+
// 诚实边界(rules-desired-config Q1 裁决):一期规则载体只有 cursor(.cursor/rules/*.mdc),
|
|
87
|
+
// claude-code 恒报空——CLAUDE.md 类载体联动属二期,另开 Brief。
|
|
88
|
+
rules: [],
|
|
86
89
|
};
|
|
87
90
|
}
|
package/dist/index.d.ts
CHANGED
|
@@ -1,4 +1,7 @@
|
|
|
1
1
|
import type { RuntimeAdapter } from "@agentoryhq/runtime-contract";
|
|
2
2
|
export { applyAlignPatch } from "./align.js";
|
|
3
3
|
export { detectClaudeCode, discoverClaudeCodeConfig, mcpFromClaudeConfig } from "./discover.js";
|
|
4
|
+
export { invokeClaudeCode, runHeadless } from "./invoke.js";
|
|
5
|
+
export { indexClaudeSessions, titleFromClaudeSessionJsonl, projectPathFromClaudeSessionJsonl, } from "./asset-index.js";
|
|
6
|
+
export { TRANSCRIPT_MESSAGE_MAX_CHARS, TRANSCRIPT_WINDOW_MESSAGES, captureTranscriptSnapshot, maskSecrets, transcriptFileForSourceKey, } from "./transcript.js";
|
|
4
7
|
export declare const claudeCodeAdapter: RuntimeAdapter;
|
package/dist/index.js
CHANGED
|
@@ -1,16 +1,26 @@
|
|
|
1
1
|
import { ADAPTER_API_VERSION } from "@agentoryhq/runtime-contract";
|
|
2
2
|
import { applyAlignPatch } from "./align.js";
|
|
3
3
|
import { detectClaudeCode, discoverClaudeCodeConfig } from "./discover.js";
|
|
4
|
+
import { invokeClaudeCode } from "./invoke.js";
|
|
5
|
+
import { indexClaudeSessions } from "./asset-index.js";
|
|
6
|
+
import { captureTranscriptSnapshot, transcriptFileForSourceKey } from "./transcript.js";
|
|
4
7
|
export { applyAlignPatch } from "./align.js";
|
|
5
8
|
export { detectClaudeCode, discoverClaudeCodeConfig, mcpFromClaudeConfig } from "./discover.js";
|
|
9
|
+
export { invokeClaudeCode, runHeadless } from "./invoke.js";
|
|
10
|
+
export { indexClaudeSessions, titleFromClaudeSessionJsonl, projectPathFromClaudeSessionJsonl, } from "./asset-index.js";
|
|
11
|
+
export { TRANSCRIPT_MESSAGE_MAX_CHARS, TRANSCRIPT_WINDOW_MESSAGES, captureTranscriptSnapshot, maskSecrets, transcriptFileForSourceKey, } from "./transcript.js";
|
|
6
12
|
export const claudeCodeAdapter = {
|
|
7
13
|
manifest: {
|
|
8
14
|
runtime: "claude-code",
|
|
9
15
|
adapterApi: ADAPTER_API_VERSION,
|
|
10
16
|
displayName: "Claude Code",
|
|
11
|
-
capabilities: ["detect", "discoverConfig", "applyAlign"],
|
|
17
|
+
capabilities: ["detect", "discoverConfig", "applyAlign", "indexSessions", "readTranscript", "invoke"],
|
|
12
18
|
},
|
|
13
19
|
detect: detectClaudeCode,
|
|
14
20
|
discoverConfig: discoverClaudeCodeConfig,
|
|
15
21
|
applyAlign: applyAlignPatch,
|
|
22
|
+
indexSessions: indexClaudeSessions,
|
|
23
|
+
transcriptFileForSourceKey,
|
|
24
|
+
captureTranscriptSnapshot,
|
|
25
|
+
invoke: invokeClaudeCode,
|
|
16
26
|
};
|
package/dist/invoke.d.ts
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { AdapterContext, InvokeRequest, InvokeResult } from "@agentoryhq/runtime-contract";
|
|
2
|
+
/**
|
|
3
|
+
* Headless 执行 runner(agent-messaging 切片 2)。
|
|
4
|
+
*
|
|
5
|
+
* 红线(ADR-005):
|
|
6
|
+
* - 只以 runtime 默认权限模型执行——本文件不得出现任何权限放宽 flag
|
|
7
|
+
* (`--dangerously-skip-permissions` / `--permission-mode` 类,AC-12 grep 钉死)。
|
|
8
|
+
* - 纯本地 spawn,零入站端口、零反连。
|
|
9
|
+
* - 超时 kill 整个进程组(detached + kill(-pid)),不留僵尸。
|
|
10
|
+
* - 输出按 maxOutputBytes 截断(字节口径),truncated 标记。
|
|
11
|
+
*/
|
|
12
|
+
export type HeadlessRunOpts = {
|
|
13
|
+
command: string;
|
|
14
|
+
args: string[];
|
|
15
|
+
cwd: string;
|
|
16
|
+
timeoutMs: number;
|
|
17
|
+
maxOutputBytes: number;
|
|
18
|
+
env?: NodeJS.ProcessEnv;
|
|
19
|
+
/** 若进程成功 spawn,则以此值回填 InvokeResult.runtimeSessionId。 */
|
|
20
|
+
sessionId?: string;
|
|
21
|
+
};
|
|
22
|
+
export declare function runHeadless(opts: HeadlessRunOpts): Promise<InvokeResult>;
|
|
23
|
+
/**
|
|
24
|
+
* Claude Code headless 单发执行(一期:单发单执行,不带上文)。
|
|
25
|
+
*
|
|
26
|
+
* `--session-id <uuid>`:自生成会话标识传给 runtime,从而确定性回填
|
|
27
|
+
* runtimeSessionId(二期 thread resume 走 `--resume <id>`,契约已预留
|
|
28
|
+
* resumeSessionId——一期调用方不得设置,此处忽略)。
|
|
29
|
+
*
|
|
30
|
+
* 二进制解析:`CLAUDE_CODE_BIN` env 覆盖(测试/非标准安装),默认 PATH 中 `claude`。
|
|
31
|
+
*/
|
|
32
|
+
export declare function invokeClaudeCode(ctx: AdapterContext, req: InvokeRequest): Promise<InvokeResult>;
|
package/dist/invoke.js
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
3
|
+
export function runHeadless(opts) {
|
|
4
|
+
return new Promise((resolve) => {
|
|
5
|
+
const started = Date.now();
|
|
6
|
+
const chunks = [];
|
|
7
|
+
let outputBytes = 0;
|
|
8
|
+
let truncated = false;
|
|
9
|
+
let timedOut = false;
|
|
10
|
+
let spawned = false;
|
|
11
|
+
let spawnError;
|
|
12
|
+
let child;
|
|
13
|
+
try {
|
|
14
|
+
child = spawn(opts.command, opts.args, {
|
|
15
|
+
cwd: opts.cwd,
|
|
16
|
+
env: opts.env ?? process.env,
|
|
17
|
+
detached: true,
|
|
18
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
catch (err) {
|
|
22
|
+
resolve({
|
|
23
|
+
outcome: "failed",
|
|
24
|
+
output: "",
|
|
25
|
+
truncated: false,
|
|
26
|
+
exitCode: null,
|
|
27
|
+
durationMs: Date.now() - started,
|
|
28
|
+
errorText: err instanceof Error ? err.message : String(err),
|
|
29
|
+
});
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
const append = (buf) => {
|
|
33
|
+
const remaining = opts.maxOutputBytes - outputBytes;
|
|
34
|
+
if (remaining <= 0) {
|
|
35
|
+
if (buf.length > 0)
|
|
36
|
+
truncated = true;
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
if (buf.length > remaining) {
|
|
40
|
+
chunks.push(buf.subarray(0, remaining));
|
|
41
|
+
outputBytes = opts.maxOutputBytes;
|
|
42
|
+
truncated = true;
|
|
43
|
+
}
|
|
44
|
+
else {
|
|
45
|
+
chunks.push(buf);
|
|
46
|
+
outputBytes += buf.length;
|
|
47
|
+
}
|
|
48
|
+
};
|
|
49
|
+
const finish = (exitCode, signal) => {
|
|
50
|
+
clearTimeout(timer);
|
|
51
|
+
const durationMs = Date.now() - started;
|
|
52
|
+
const output = Buffer.concat(chunks).toString("utf8");
|
|
53
|
+
const base = { output, truncated, durationMs };
|
|
54
|
+
if (spawnError) {
|
|
55
|
+
resolve({
|
|
56
|
+
...base,
|
|
57
|
+
outcome: "failed",
|
|
58
|
+
exitCode: null,
|
|
59
|
+
errorText: spawnError.message,
|
|
60
|
+
});
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
if (spawned && opts.sessionId) {
|
|
64
|
+
base.runtimeSessionId = opts.sessionId;
|
|
65
|
+
}
|
|
66
|
+
if (timedOut) {
|
|
67
|
+
resolve({
|
|
68
|
+
...base,
|
|
69
|
+
outcome: "timeout",
|
|
70
|
+
exitCode,
|
|
71
|
+
errorText: `执行超时(超过 ${opts.timeoutMs}ms 上限)`,
|
|
72
|
+
});
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
if (exitCode === 0) {
|
|
76
|
+
resolve({ ...base, outcome: "done", exitCode: 0 });
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
resolve({
|
|
80
|
+
...base,
|
|
81
|
+
outcome: "failed",
|
|
82
|
+
exitCode,
|
|
83
|
+
errorText: exitCode === null
|
|
84
|
+
? `runtime 被信号终止(${signal ?? "unknown"})`
|
|
85
|
+
: `runtime 退出码 ${exitCode}`,
|
|
86
|
+
});
|
|
87
|
+
};
|
|
88
|
+
const timer = setTimeout(() => {
|
|
89
|
+
timedOut = true;
|
|
90
|
+
// 进程组终止:detached spawn 的负 pid = 整组(含 runtime 再派生的子进程)。
|
|
91
|
+
try {
|
|
92
|
+
if (child.pid !== undefined)
|
|
93
|
+
process.kill(-child.pid, "SIGKILL");
|
|
94
|
+
else
|
|
95
|
+
child.kill("SIGKILL");
|
|
96
|
+
}
|
|
97
|
+
catch {
|
|
98
|
+
try {
|
|
99
|
+
child.kill("SIGKILL");
|
|
100
|
+
}
|
|
101
|
+
catch {
|
|
102
|
+
/* already gone */
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}, opts.timeoutMs);
|
|
106
|
+
child.on("spawn", () => {
|
|
107
|
+
spawned = true;
|
|
108
|
+
});
|
|
109
|
+
child.on("error", (err) => {
|
|
110
|
+
// spawn 失败(如 ENOENT):'close' 可能不再触发,这里直接收口。
|
|
111
|
+
// 重复 resolve 无副作用(Promise 只认第一次)。
|
|
112
|
+
spawnError = err;
|
|
113
|
+
finish(null, null);
|
|
114
|
+
});
|
|
115
|
+
child.stdout?.on("data", (buf) => append(buf));
|
|
116
|
+
child.stderr?.on("data", (buf) => append(buf));
|
|
117
|
+
child.on("close", (code, signal) => finish(code, signal));
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* Claude Code headless 单发执行(一期:单发单执行,不带上文)。
|
|
122
|
+
*
|
|
123
|
+
* `--session-id <uuid>`:自生成会话标识传给 runtime,从而确定性回填
|
|
124
|
+
* runtimeSessionId(二期 thread resume 走 `--resume <id>`,契约已预留
|
|
125
|
+
* resumeSessionId——一期调用方不得设置,此处忽略)。
|
|
126
|
+
*
|
|
127
|
+
* 二进制解析:`CLAUDE_CODE_BIN` env 覆盖(测试/非标准安装),默认 PATH 中 `claude`。
|
|
128
|
+
*/
|
|
129
|
+
export async function invokeClaudeCode(ctx, req) {
|
|
130
|
+
const sessionId = randomUUID();
|
|
131
|
+
const bin = process.env.CLAUDE_CODE_BIN?.trim() || "claude";
|
|
132
|
+
const args = ["-p", req.prompt, "--session-id", sessionId];
|
|
133
|
+
// req.resumeSessionId:二期预留,一期忽略(见 playbook 禁止清单「多轮接续二期」)。
|
|
134
|
+
return runHeadless({
|
|
135
|
+
command: bin,
|
|
136
|
+
args,
|
|
137
|
+
cwd: req.cwd || ctx.cwd,
|
|
138
|
+
timeoutMs: req.timeoutMs,
|
|
139
|
+
maxOutputBytes: req.maxOutputBytes,
|
|
140
|
+
sessionId,
|
|
141
|
+
});
|
|
142
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { test } from "node:test";
|
|
6
|
+
import { invokeClaudeCode, runHeadless } from "./invoke.js";
|
|
7
|
+
const baseOpts = { timeoutMs: 10_000, maxOutputBytes: 64 * 1024 };
|
|
8
|
+
test("runHeadless: done captures stdout+stderr and exit code 0", async () => {
|
|
9
|
+
const res = await runHeadless({
|
|
10
|
+
command: "/bin/sh",
|
|
11
|
+
args: ["-c", "echo out-line; echo err-line >&2"],
|
|
12
|
+
cwd: os.tmpdir(),
|
|
13
|
+
...baseOpts,
|
|
14
|
+
});
|
|
15
|
+
assert.equal(res.outcome, "done");
|
|
16
|
+
assert.equal(res.exitCode, 0);
|
|
17
|
+
assert.match(res.output, /out-line/);
|
|
18
|
+
assert.match(res.output, /err-line/);
|
|
19
|
+
assert.equal(res.truncated, false);
|
|
20
|
+
assert.ok(res.durationMs >= 0);
|
|
21
|
+
});
|
|
22
|
+
test("runHeadless: non-zero exit → failed with 退出码 errorText", async () => {
|
|
23
|
+
const res = await runHeadless({
|
|
24
|
+
command: "/bin/sh",
|
|
25
|
+
args: ["-c", "echo partial; exit 3"],
|
|
26
|
+
cwd: os.tmpdir(),
|
|
27
|
+
...baseOpts,
|
|
28
|
+
});
|
|
29
|
+
assert.equal(res.outcome, "failed");
|
|
30
|
+
assert.equal(res.exitCode, 3);
|
|
31
|
+
assert.match(res.output, /partial/);
|
|
32
|
+
assert.match(res.errorText ?? "", /退出码 3/);
|
|
33
|
+
});
|
|
34
|
+
test("runHeadless: timeout kills the process group and keeps partial output (AC-7)", async () => {
|
|
35
|
+
const started = Date.now();
|
|
36
|
+
const res = await runHeadless({
|
|
37
|
+
// 子进程再派生孙进程——进程组 kill 必须一起收走。
|
|
38
|
+
// 时长用唯一标记 61.7(机器上有无关 watchdog 循环 sleep 60,全局断言会误伤)。
|
|
39
|
+
command: "/bin/sh",
|
|
40
|
+
args: ["-c", "echo before-hang; sleep 61.7 & sleep 61.7"],
|
|
41
|
+
cwd: os.tmpdir(),
|
|
42
|
+
timeoutMs: 400,
|
|
43
|
+
maxOutputBytes: 64 * 1024,
|
|
44
|
+
});
|
|
45
|
+
assert.equal(res.outcome, "timeout");
|
|
46
|
+
assert.match(res.output, /before-hang/);
|
|
47
|
+
assert.match(res.errorText ?? "", /执行超时/);
|
|
48
|
+
assert.ok(Date.now() - started < 10_000, "超时后应立即收口,不等 sleep");
|
|
49
|
+
// 进程组确实被收走(负 pid kill 不残留)。模式 61[.]7 防 pgrep 包装 sh 自匹配。
|
|
50
|
+
await new Promise((r) => setTimeout(r, 200));
|
|
51
|
+
const pgrep = await runHeadless({
|
|
52
|
+
command: "/bin/sh",
|
|
53
|
+
args: ["-c", "pgrep -f 'sleep 61[.]7' | head -1 || true"],
|
|
54
|
+
cwd: os.tmpdir(),
|
|
55
|
+
...baseOpts,
|
|
56
|
+
});
|
|
57
|
+
assert.equal(pgrep.output.trim(), "", "timeout 后不应残留本测试的 sleep 进程");
|
|
58
|
+
});
|
|
59
|
+
test("runHeadless: output beyond maxOutputBytes is truncated with flag", async () => {
|
|
60
|
+
const res = await runHeadless({
|
|
61
|
+
command: "/bin/sh",
|
|
62
|
+
args: ["-c", "printf 'x%.0s' $(seq 1 5000)"],
|
|
63
|
+
cwd: os.tmpdir(),
|
|
64
|
+
timeoutMs: 10_000,
|
|
65
|
+
maxOutputBytes: 100,
|
|
66
|
+
});
|
|
67
|
+
assert.equal(res.outcome, "done");
|
|
68
|
+
assert.equal(res.truncated, true);
|
|
69
|
+
assert.equal(Buffer.byteLength(res.output, "utf8"), 100);
|
|
70
|
+
});
|
|
71
|
+
test("runHeadless: spawn failure (ENOENT) → failed, exitCode null", async () => {
|
|
72
|
+
const res = await runHeadless({
|
|
73
|
+
command: "/definitely/not/a/binary",
|
|
74
|
+
args: [],
|
|
75
|
+
cwd: os.tmpdir(),
|
|
76
|
+
...baseOpts,
|
|
77
|
+
});
|
|
78
|
+
assert.equal(res.outcome, "failed");
|
|
79
|
+
assert.equal(res.exitCode, null);
|
|
80
|
+
assert.ok(res.errorText);
|
|
81
|
+
assert.equal(res.runtimeSessionId, undefined, "未 spawn 成功不得回填会话标识");
|
|
82
|
+
});
|
|
83
|
+
test("invokeClaudeCode: headless args + runtimeSessionId 回填 + 零权限放宽 flag (AC-12)", async () => {
|
|
84
|
+
const root = fs.mkdtempSync(path.join(os.tmpdir(), "agy-claude-i-"));
|
|
85
|
+
const argsFile = path.join(root, "args.txt");
|
|
86
|
+
const fakeBin = path.join(root, "fake-claude.sh");
|
|
87
|
+
fs.writeFileSync(fakeBin, [
|
|
88
|
+
"#!/bin/sh",
|
|
89
|
+
`printf '%s\\n' "$@" > ${JSON.stringify(argsFile)}`,
|
|
90
|
+
"echo fake-result-text",
|
|
91
|
+
"exit 0",
|
|
92
|
+
].join("\n"), { mode: 0o755 });
|
|
93
|
+
const prev = process.env.CLAUDE_CODE_BIN;
|
|
94
|
+
process.env.CLAUDE_CODE_BIN = fakeBin;
|
|
95
|
+
try {
|
|
96
|
+
const res = await invokeClaudeCode({ homeDir: root, cwd: root }, { prompt: "总结最近一周构建失败原因", timeoutMs: 10_000, maxOutputBytes: 4096, cwd: root });
|
|
97
|
+
assert.equal(res.outcome, "done");
|
|
98
|
+
assert.match(res.output, /fake-result-text/);
|
|
99
|
+
assert.ok(res.runtimeSessionId, "spawn 成功应回填 runtimeSessionId");
|
|
100
|
+
assert.match(res.runtimeSessionId ?? "", /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/, "runtimeSessionId 应为 UUID(二期 --resume 可用)");
|
|
101
|
+
const sentArgs = fs.readFileSync(argsFile, "utf8").split("\n").filter(Boolean);
|
|
102
|
+
assert.deepEqual(sentArgs, [
|
|
103
|
+
"-p",
|
|
104
|
+
"总结最近一周构建失败原因",
|
|
105
|
+
"--session-id",
|
|
106
|
+
res.runtimeSessionId ?? "",
|
|
107
|
+
]);
|
|
108
|
+
// AC-12 红线:参数面零权限放宽 flag
|
|
109
|
+
for (const arg of sentArgs) {
|
|
110
|
+
assert.ok(!/dangerously|skip-permissions|permission-mode|yolo|bypass/i.test(arg), `不得注入权限放宽 flag: ${arg}`);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
finally {
|
|
114
|
+
if (prev === undefined)
|
|
115
|
+
delete process.env.CLAUDE_CODE_BIN;
|
|
116
|
+
else
|
|
117
|
+
process.env.CLAUDE_CODE_BIN = prev;
|
|
118
|
+
}
|
|
119
|
+
});
|
|
120
|
+
test("invokeClaudeCode: resumeSessionId 一期忽略(不产生 --resume 类参数)", async () => {
|
|
121
|
+
const root = fs.mkdtempSync(path.join(os.tmpdir(), "agy-claude-r-"));
|
|
122
|
+
const argsFile = path.join(root, "args.txt");
|
|
123
|
+
const fakeBin = path.join(root, "fake-claude.sh");
|
|
124
|
+
fs.writeFileSync(fakeBin, ["#!/bin/sh", `printf '%s\\n' "$@" > ${JSON.stringify(argsFile)}`, "exit 0"].join("\n"), { mode: 0o755 });
|
|
125
|
+
const prev = process.env.CLAUDE_CODE_BIN;
|
|
126
|
+
process.env.CLAUDE_CODE_BIN = fakeBin;
|
|
127
|
+
try {
|
|
128
|
+
await invokeClaudeCode({ homeDir: root, cwd: root }, {
|
|
129
|
+
prompt: "hi",
|
|
130
|
+
timeoutMs: 10_000,
|
|
131
|
+
maxOutputBytes: 4096,
|
|
132
|
+
cwd: root,
|
|
133
|
+
resumeSessionId: "should-be-ignored",
|
|
134
|
+
});
|
|
135
|
+
const sentArgs = fs.readFileSync(argsFile, "utf8");
|
|
136
|
+
assert.ok(!sentArgs.includes("should-be-ignored"));
|
|
137
|
+
assert.ok(!sentArgs.includes("--resume"));
|
|
138
|
+
}
|
|
139
|
+
finally {
|
|
140
|
+
if (prev === undefined)
|
|
141
|
+
delete process.env.CLAUDE_CODE_BIN;
|
|
142
|
+
else
|
|
143
|
+
process.env.CLAUDE_CODE_BIN = prev;
|
|
144
|
+
}
|
|
145
|
+
});
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { TranscriptSnapshot } from "@agentoryhq/shared";
|
|
2
|
+
/**
|
|
3
|
+
* Node-side Claude Code transcript capture (claude-session-index, AC-4).
|
|
4
|
+
* Same discipline as the cursor adapter: read jsonl locally → filter dialog
|
|
5
|
+
* rows (human gate for user rows, assistant text blocks) → mask secret-shaped
|
|
6
|
+
* strings → keep the near-end window. Only the masked, windowed snapshot ever
|
|
7
|
+
* leaves the machine; raw body is never logged.
|
|
8
|
+
*/
|
|
9
|
+
/** 近端窗口上限(与 cursor transcript.ts 同值钉死) */
|
|
10
|
+
export declare const TRANSCRIPT_WINDOW_MESSAGES = 40;
|
|
11
|
+
/** 单条消息字符上限(同上) */
|
|
12
|
+
export declare const TRANSCRIPT_MESSAGE_MAX_CHARS = 2000;
|
|
13
|
+
/** Mask secret-shaped substrings BEFORE anything leaves the node (AC-4). */
|
|
14
|
+
export declare function maskSecrets(text: string): string;
|
|
15
|
+
/**
|
|
16
|
+
* Read the Claude session jsonl at `file` and build a masked, windowed
|
|
17
|
+
* snapshot. Returns undefined when the file is missing/unreadable; an empty
|
|
18
|
+
* snapshot for a session with no extractable dialog rows.
|
|
19
|
+
*/
|
|
20
|
+
export declare function captureTranscriptSnapshot(file: string, now?: Date): TranscriptSnapshot | undefined;
|
|
21
|
+
export type TranscriptFetchOpts = {
|
|
22
|
+
homeDir?: string;
|
|
23
|
+
};
|
|
24
|
+
/**
|
|
25
|
+
* Resolve a session jsonl path for a claude sourceKey
|
|
26
|
+
* (`claude-session:<abs path>` as emitted by asset-index.ts).
|
|
27
|
+
* Only files under `~/.claude/projects/` qualify — no arbitrary path
|
|
28
|
+
* scanning (invariants §4; brief §4 不做: no AGENTORY_CLAUDE_ROOT override,
|
|
29
|
+
* default scan face stays narrow).
|
|
30
|
+
*/
|
|
31
|
+
export declare function transcriptFileForSourceKey(sourceKey: string, homeDir?: string): string | undefined;
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
/**
|
|
5
|
+
* Node-side Claude Code transcript capture (claude-session-index, AC-4).
|
|
6
|
+
* Same discipline as the cursor adapter: read jsonl locally → filter dialog
|
|
7
|
+
* rows (human gate for user rows, assistant text blocks) → mask secret-shaped
|
|
8
|
+
* strings → keep the near-end window. Only the masked, windowed snapshot ever
|
|
9
|
+
* leaves the machine; raw body is never logged.
|
|
10
|
+
*/
|
|
11
|
+
/** 近端窗口上限(与 cursor transcript.ts 同值钉死) */
|
|
12
|
+
export const TRANSCRIPT_WINDOW_MESSAGES = 40;
|
|
13
|
+
/** 单条消息字符上限(同上) */
|
|
14
|
+
export const TRANSCRIPT_MESSAGE_MAX_CHARS = 2000;
|
|
15
|
+
const SECRET_PATTERNS = [
|
|
16
|
+
{ re: /\bsk-[A-Za-z0-9_\-]{16,}/g, mask: "sk-••••••••" },
|
|
17
|
+
{ re: /\bghp_[A-Za-z0-9]{20,}/g, mask: "ghp_••••••••" },
|
|
18
|
+
{ re: /\bgho_[A-Za-z0-9]{20,}/g, mask: "gho_••••••••" },
|
|
19
|
+
{ re: /\bgithub_pat_[A-Za-z0-9_]{20,}/g, mask: "github_pat_••••••••" },
|
|
20
|
+
{ re: /\bxox[baprs]-[A-Za-z0-9\-]{16,}/g, mask: "xox•-••••••••" },
|
|
21
|
+
{ re: /\bAKIA[0-9A-Z]{16}\b/g, mask: "AKIA••••••••" },
|
|
22
|
+
{ re: /(?:password|passwd|pwd|secret|token|api[_-]?key)\s*[:=]\s*\S+/gi, mask: "password=••••••••" },
|
|
23
|
+
// 长随机密钥形态(hex/base64 ≥ 32 字符,独立成词)
|
|
24
|
+
{ re: /\b[A-Za-z0-9+/]{40,}={0,2}\b/g, mask: "••••••••" },
|
|
25
|
+
{ re: /\b[0-9a-f]{32,}\b/gi, mask: "••••••••" },
|
|
26
|
+
];
|
|
27
|
+
/** Mask secret-shaped substrings BEFORE anything leaves the node (AC-4). */
|
|
28
|
+
export function maskSecrets(text) {
|
|
29
|
+
let out = text;
|
|
30
|
+
for (const { re, mask } of SECRET_PATTERNS) {
|
|
31
|
+
out = out.replace(re, mask);
|
|
32
|
+
}
|
|
33
|
+
return out;
|
|
34
|
+
}
|
|
35
|
+
function isHumanOrigin(row) {
|
|
36
|
+
const origin = row.origin;
|
|
37
|
+
return !!origin && typeof origin === "object" &&
|
|
38
|
+
origin.kind === "human";
|
|
39
|
+
}
|
|
40
|
+
/** Human gate (brief §3 陷阱): user + non-tool_result content + origin.kind==="human". */
|
|
41
|
+
function humanTextFromUserRow(row) {
|
|
42
|
+
if (row.type !== "user" || row.isSidechain === true)
|
|
43
|
+
return "";
|
|
44
|
+
if (!isHumanOrigin(row))
|
|
45
|
+
return "";
|
|
46
|
+
const msg = row.message;
|
|
47
|
+
if (!msg || typeof msg !== "object")
|
|
48
|
+
return "";
|
|
49
|
+
const content = msg.content;
|
|
50
|
+
if (Array.isArray(content) &&
|
|
51
|
+
content.some((b) => b && typeof b === "object" && b.type === "tool_result")) {
|
|
52
|
+
return "";
|
|
53
|
+
}
|
|
54
|
+
if (typeof content === "string")
|
|
55
|
+
return content;
|
|
56
|
+
if (Array.isArray(content)) {
|
|
57
|
+
const parts = [];
|
|
58
|
+
for (const b of content) {
|
|
59
|
+
if (b && typeof b === "object" && typeof b.text === "string") {
|
|
60
|
+
parts.push(b.text);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
return parts.join(" ");
|
|
64
|
+
}
|
|
65
|
+
return "";
|
|
66
|
+
}
|
|
67
|
+
/** Assistant rows: aggregate message.content text blocks (skip thinking/tool_use). */
|
|
68
|
+
function assistantTextFromRow(row) {
|
|
69
|
+
if (row.type !== "assistant" || row.isSidechain === true)
|
|
70
|
+
return "";
|
|
71
|
+
const msg = row.message;
|
|
72
|
+
if (!msg || typeof msg !== "object")
|
|
73
|
+
return "";
|
|
74
|
+
const content = msg.content;
|
|
75
|
+
if (!Array.isArray(content))
|
|
76
|
+
return "";
|
|
77
|
+
const parts = [];
|
|
78
|
+
for (const b of content) {
|
|
79
|
+
if (b && typeof b === "object" && b.type === "text" &&
|
|
80
|
+
typeof b.text === "string") {
|
|
81
|
+
parts.push(b.text);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
return parts.join(" ");
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* Read the Claude session jsonl at `file` and build a masked, windowed
|
|
88
|
+
* snapshot. Returns undefined when the file is missing/unreadable; an empty
|
|
89
|
+
* snapshot for a session with no extractable dialog rows.
|
|
90
|
+
*/
|
|
91
|
+
export function captureTranscriptSnapshot(file, now = new Date()) {
|
|
92
|
+
let raw;
|
|
93
|
+
try {
|
|
94
|
+
raw = fs.readFileSync(file, "utf8");
|
|
95
|
+
}
|
|
96
|
+
catch {
|
|
97
|
+
return undefined;
|
|
98
|
+
}
|
|
99
|
+
const sourceKey = `claude-session:${file}`;
|
|
100
|
+
const all = [];
|
|
101
|
+
for (const line of raw.split("\n")) {
|
|
102
|
+
if (!line.trim())
|
|
103
|
+
continue;
|
|
104
|
+
let o;
|
|
105
|
+
try {
|
|
106
|
+
o = JSON.parse(line);
|
|
107
|
+
}
|
|
108
|
+
catch {
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
if (!o || typeof o !== "object")
|
|
112
|
+
continue;
|
|
113
|
+
const row = o;
|
|
114
|
+
// Unknown row types (mode/system/snapshot/attachment/…) fall through both
|
|
115
|
+
// extractors as "" — silently skipped, never crash (brief §7 schema-drift guard).
|
|
116
|
+
let role;
|
|
117
|
+
let text = "";
|
|
118
|
+
const human = humanTextFromUserRow(row);
|
|
119
|
+
if (human) {
|
|
120
|
+
role = "user";
|
|
121
|
+
text = human;
|
|
122
|
+
}
|
|
123
|
+
else {
|
|
124
|
+
const assistant = assistantTextFromRow(row);
|
|
125
|
+
if (assistant) {
|
|
126
|
+
role = "assistant";
|
|
127
|
+
text = assistant;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
if (!role || !text.trim())
|
|
131
|
+
continue;
|
|
132
|
+
if (text.length > TRANSCRIPT_MESSAGE_MAX_CHARS) {
|
|
133
|
+
text = `${text.slice(0, TRANSCRIPT_MESSAGE_MAX_CHARS)}…`;
|
|
134
|
+
}
|
|
135
|
+
const ts = typeof row.timestamp === "string" ? row.timestamp : "";
|
|
136
|
+
all.push({ role, ts, text });
|
|
137
|
+
}
|
|
138
|
+
const windowMessages = all.length > TRANSCRIPT_WINDOW_MESSAGES
|
|
139
|
+
? all.slice(-TRANSCRIPT_WINDOW_MESSAGES)
|
|
140
|
+
: all;
|
|
141
|
+
const truncated = all.length > windowMessages.length;
|
|
142
|
+
return {
|
|
143
|
+
sourceKey,
|
|
144
|
+
messages: windowMessages.map((m) => ({ ...m, text: maskSecrets(m.text) })),
|
|
145
|
+
truncated,
|
|
146
|
+
windowTotal: truncated ? `${windowMessages.length} / ${all.length} 条` : undefined,
|
|
147
|
+
capturedAt: now.toISOString(),
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
/**
|
|
151
|
+
* Resolve a session jsonl path for a claude sourceKey
|
|
152
|
+
* (`claude-session:<abs path>` as emitted by asset-index.ts).
|
|
153
|
+
* Only files under `~/.claude/projects/` qualify — no arbitrary path
|
|
154
|
+
* scanning (invariants §4; brief §4 不做: no AGENTORY_CLAUDE_ROOT override,
|
|
155
|
+
* default scan face stays narrow).
|
|
156
|
+
*/
|
|
157
|
+
export function transcriptFileForSourceKey(sourceKey, homeDir) {
|
|
158
|
+
const prefix = "claude-session:";
|
|
159
|
+
if (!sourceKey.startsWith(prefix))
|
|
160
|
+
return undefined;
|
|
161
|
+
const file = sourceKey.slice(prefix.length);
|
|
162
|
+
const home = homeDir ?? os.homedir();
|
|
163
|
+
const root = path.join(home, ".claude", "projects");
|
|
164
|
+
try {
|
|
165
|
+
const rel = path.relative(root, file);
|
|
166
|
+
if (rel.startsWith("..") || path.isAbsolute(rel))
|
|
167
|
+
return undefined;
|
|
168
|
+
}
|
|
169
|
+
catch {
|
|
170
|
+
return undefined;
|
|
171
|
+
}
|
|
172
|
+
return file;
|
|
173
|
+
}
|
package/package.json
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@agentoryhq/adapter-claude-code",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.3",
|
|
4
4
|
"description": "Agentory RuntimeAdapter for Claude Code",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"type": "module",
|
|
7
|
-
"files": [
|
|
7
|
+
"files": [
|
|
8
|
+
"dist"
|
|
9
|
+
],
|
|
8
10
|
"exports": {
|
|
9
11
|
".": {
|
|
10
12
|
"types": "./dist/index.d.ts",
|
|
@@ -20,8 +22,7 @@
|
|
|
20
22
|
},
|
|
21
23
|
"repository": {
|
|
22
24
|
"type": "git",
|
|
23
|
-
"url": "git+https://github.com/tunsuy/agentory-node.git"
|
|
24
|
-
"directory": "adapters/claude-code"
|
|
25
|
+
"url": "git+https://github.com/tunsuy/agentory-node.git"
|
|
25
26
|
},
|
|
26
27
|
"publishConfig": {
|
|
27
28
|
"access": "public"
|