@hmharness/agent 0.8.1 → 0.8.4
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/i18n.d.ts +16 -0
- package/dist/i18n.js +32 -2
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/project.d.ts +67 -0
- package/dist/project.js +265 -0
- package/dist/runner.d.ts +3 -0
- package/dist/runner.js +4 -1
- package/package.json +6 -5
package/dist/i18n.d.ts
CHANGED
|
@@ -74,6 +74,22 @@ export interface Strings {
|
|
|
74
74
|
cmdResumeNone: string;
|
|
75
75
|
cmdResumeNotFound: (p: string) => string;
|
|
76
76
|
cmdResumeLoaded: (n: number) => string;
|
|
77
|
+
/** Codex-style resume picker (v0.8.2 transplant) */
|
|
78
|
+
pickerTitle: string;
|
|
79
|
+
pickerHint1: string;
|
|
80
|
+
pickerHint2: string;
|
|
81
|
+
pickerEmpty: string;
|
|
82
|
+
pickerNoMatch: string;
|
|
83
|
+
pickerLoading: string;
|
|
84
|
+
pickerMore: string;
|
|
85
|
+
pickerPreviewNone: string;
|
|
86
|
+
pickerLabelFilter: string;
|
|
87
|
+
pickerLabelSort: string;
|
|
88
|
+
pickerValCwd: string;
|
|
89
|
+
pickerValAll: string;
|
|
90
|
+
pickerValUpdated: string;
|
|
91
|
+
pickerValCreated: string;
|
|
92
|
+
pickerPos: (pos: number, total: number, pct: string) => string;
|
|
77
93
|
tuiRadarScanning: string;
|
|
78
94
|
tuiEvolveDone: (proposals: number, insights: number, notes: number) => string;
|
|
79
95
|
tuiPassRate: (pct: string) => string;
|
package/dist/i18n.js
CHANGED
|
@@ -56,10 +56,25 @@ const zh = {
|
|
|
56
56
|
tuiScrolled: '↑ 已上滚 · PgDn/End/滚轮 回底',
|
|
57
57
|
tuiWebHint: '浏览器界面: 在另一个终端运行 hmh web --port=7788',
|
|
58
58
|
updateHint: (latest) => `新版本 ${latest} 可用: npm i -g @hmharness/cli 更新`,
|
|
59
|
-
cmdResume: '恢复历史会话(/resume 打开选择器,↑↓ 选择,Enter
|
|
59
|
+
cmdResume: '恢复历史会话(/resume 打开选择器,↑↓ 选择,Enter 载入;输入即过滤)',
|
|
60
60
|
cmdResumeNone: '还没有历史会话',
|
|
61
61
|
cmdResumeNotFound: (p) => `没有匹配 "${p}" 的会话`,
|
|
62
62
|
cmdResumeLoaded: (n) => `已恢复会话(${n} 条消息),直接输入内容继续对话`,
|
|
63
|
+
pickerTitle: '恢复会话',
|
|
64
|
+
pickerHint1: 'Enter 恢复 · ↑↓ 浏览 · Tab 切筛选/排序 · ←→ 调整',
|
|
65
|
+
pickerHint2: 'Esc 关闭 · 输入即过滤(标题/id/目录/分支)',
|
|
66
|
+
pickerEmpty: '还没有会话',
|
|
67
|
+
pickerNoMatch: '没有匹配的会话',
|
|
68
|
+
pickerLoading: '正在加载会话…',
|
|
69
|
+
pickerMore: '↓ 加载更多…',
|
|
70
|
+
pickerPreviewNone: '(无消息)',
|
|
71
|
+
pickerLabelFilter: '筛选',
|
|
72
|
+
pickerLabelSort: '排序',
|
|
73
|
+
pickerValCwd: '当前目录',
|
|
74
|
+
pickerValAll: '全部',
|
|
75
|
+
pickerValUpdated: '最近更新',
|
|
76
|
+
pickerValCreated: '创建时间',
|
|
77
|
+
pickerPos: (pos, total, pct) => ` ${pos} / ${total} · ${pct}% `,
|
|
63
78
|
tuiRadarScanning: '雷达扫描中…',
|
|
64
79
|
tuiEvolveDone: (p, i, n) => `evolve 完成: ${p} 提案 · 洞察 ${i} · 记忆 ${n}`,
|
|
65
80
|
tuiPassRate: (pct) => `pass rate: ${pct}`,
|
|
@@ -151,10 +166,25 @@ const en = {
|
|
|
151
166
|
tuiScrolled: '↑ scrolled up · PgDn/End/wheel back to bottom',
|
|
152
167
|
tuiWebHint: 'web UI: run hmh web --port=7788 in another terminal',
|
|
153
168
|
updateHint: (latest) => `version ${latest} available - update with: npm i -g @hmharness/cli`,
|
|
154
|
-
cmdResume: 'resume a past session (/resume opens the picker; arrows + Enter, type
|
|
169
|
+
cmdResume: 'resume a past session (/resume opens the picker; arrows + Enter, type to filter)',
|
|
155
170
|
cmdResumeNone: 'no past sessions yet',
|
|
156
171
|
cmdResumeNotFound: (p) => `no session matches "${p}"`,
|
|
157
172
|
cmdResumeLoaded: (n) => `session resumed (${n} messages) - just type to continue`,
|
|
173
|
+
pickerTitle: 'Resume session',
|
|
174
|
+
pickerHint1: 'Enter resume · ↑↓ browse · Tab filter/sort · ←→ change',
|
|
175
|
+
pickerHint2: 'Esc close · type to filter (title/id/dir/branch)',
|
|
176
|
+
pickerEmpty: 'no sessions yet',
|
|
177
|
+
pickerNoMatch: 'no results for your search',
|
|
178
|
+
pickerLoading: 'Loading sessions…',
|
|
179
|
+
pickerMore: '↓ loading more…',
|
|
180
|
+
pickerPreviewNone: '(no message yet)',
|
|
181
|
+
pickerLabelFilter: 'Filter',
|
|
182
|
+
pickerLabelSort: 'Sort',
|
|
183
|
+
pickerValCwd: 'Cwd',
|
|
184
|
+
pickerValAll: 'All',
|
|
185
|
+
pickerValUpdated: 'Updated',
|
|
186
|
+
pickerValCreated: 'Created',
|
|
187
|
+
pickerPos: (pos, total, pct) => ` ${pos} / ${total} · ${pct}% `,
|
|
158
188
|
tuiRadarScanning: 'scanning radar…',
|
|
159
189
|
tuiEvolveDone: (p, i, n) => `evolve done: ${p} proposals · ${i} insights · ${n} notes`,
|
|
160
190
|
tuiPassRate: (pct) => `pass rate: ${pct}`,
|
package/dist/index.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
export { baseTools, readFileTool, writeFileTool, listDirTool, runCommandTool, rememberTool, seeImageTool } from './tools.ts';
|
|
2
2
|
export { manifestFor, capabilityReport, authorize, type CapabilityManifest, type CapabilityRisk, type PolicyMode } from './capability.ts';
|
|
3
|
+
export { checkpointProject, createProject, findProject, interruptProject, listProjects, loadProject, projectFor, releaseProject, restoreCheckpoint, resumeBundle, transitionProject, attachRun, newProjectId, type CheckpointRef, type DecisionEntry, type ProjectRecord, type ProjectState, } from './project.ts';
|
|
3
4
|
export { buildSystemPrompt } from './prompt.ts';
|
|
4
5
|
export { strings, type Locale, type Strings } from './i18n.ts';
|
|
5
6
|
export { makeSpawnTool, MAX_SPAWN_DEPTH, type SpawnBase } from './spawn.ts';
|
package/dist/index.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
export { baseTools, readFileTool, writeFileTool, listDirTool, runCommandTool, rememberTool, seeImageTool } from "./tools.js";
|
|
2
2
|
export { manifestFor, capabilityReport, authorize } from "./capability.js";
|
|
3
|
+
export { checkpointProject, createProject, findProject, interruptProject, listProjects, loadProject, projectFor, releaseProject, restoreCheckpoint, resumeBundle, transitionProject, attachRun, newProjectId, } from "./project.js";
|
|
3
4
|
export { buildSystemPrompt } from "./prompt.js";
|
|
4
5
|
export { strings } from "./i18n.js";
|
|
5
6
|
export { makeSpawnTool, MAX_SPAWN_DEPTH } from "./spawn.js";
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { type SandboxSession } from '@hmharness/sandbox';
|
|
2
|
+
export type ProjectState = 'created' | 'active' | 'paused' | 'completed' | 'archived';
|
|
3
|
+
export interface CheckpointRef {
|
|
4
|
+
id: string;
|
|
5
|
+
label?: string;
|
|
6
|
+
/** git tree sha, or 'copy:<relative dir>' for non-git workspaces */
|
|
7
|
+
tree: string;
|
|
8
|
+
time: string;
|
|
9
|
+
files: number;
|
|
10
|
+
}
|
|
11
|
+
export interface DecisionEntry {
|
|
12
|
+
time: string;
|
|
13
|
+
kind: 'checkpoint' | 'restore' | 'interrupt' | 'decision' | 'state' | 'attach-run' | 'release';
|
|
14
|
+
summary: string;
|
|
15
|
+
ref?: string;
|
|
16
|
+
}
|
|
17
|
+
export interface ProjectRecord {
|
|
18
|
+
projectId: string;
|
|
19
|
+
name?: string;
|
|
20
|
+
workspace: string;
|
|
21
|
+
state: ProjectState;
|
|
22
|
+
createdAt: string;
|
|
23
|
+
updatedAt: string;
|
|
24
|
+
checkpoints: CheckpointRef[];
|
|
25
|
+
tasks: string[];
|
|
26
|
+
decisions: DecisionEntry[];
|
|
27
|
+
memory: string;
|
|
28
|
+
skills: string[];
|
|
29
|
+
runs: Array<{
|
|
30
|
+
sessionId: string;
|
|
31
|
+
time: string;
|
|
32
|
+
}>;
|
|
33
|
+
benchmarks: string[];
|
|
34
|
+
releases: Array<{
|
|
35
|
+
version: string;
|
|
36
|
+
checkpointId?: string;
|
|
37
|
+
time: string;
|
|
38
|
+
notes?: string;
|
|
39
|
+
}>;
|
|
40
|
+
}
|
|
41
|
+
export declare function newProjectId(): string;
|
|
42
|
+
export declare function createProject(home: string, workspace: string, name?: string): Promise<ProjectRecord>;
|
|
43
|
+
export declare function listProjects(home: string): Promise<ProjectRecord[]>;
|
|
44
|
+
export declare function loadProject(home: string, projectId: string): Promise<ProjectRecord | null>;
|
|
45
|
+
/** Find the newest project bound to this workspace (null if none). */
|
|
46
|
+
export declare function findProject(home: string, workspace: string): Promise<ProjectRecord | null>;
|
|
47
|
+
/** find-or-create + auto-activate (the default entry point for CLIs). */
|
|
48
|
+
export declare function projectFor(home: string, workspace: string, name?: string): Promise<ProjectRecord>;
|
|
49
|
+
export declare function transitionProject(home: string, rec: ProjectRecord, next: ProjectState): Promise<ProjectRecord>;
|
|
50
|
+
/** Byte-exact snapshot via git plumbing (user index/refs/worktree untouched);
|
|
51
|
+
* directory-copy fallback for non-git workspaces. */
|
|
52
|
+
export declare function checkpointProject(home: string, rec: ProjectRecord, label?: string): Promise<CheckpointRef>;
|
|
53
|
+
/** Materialize a checkpoint into a fresh sandbox copy for inspection or a
|
|
54
|
+
* recovery run. The user's workspace is never touched. */
|
|
55
|
+
export declare function restoreCheckpoint(home: string, rec: ProjectRecord, checkpointId: string): Promise<SandboxSession>;
|
|
56
|
+
/** Remember the rollout a project conversation lives in (0.8.2 append semantics). */
|
|
57
|
+
export declare function attachRun(home: string, rec: ProjectRecord, sessionId: string): Promise<void>;
|
|
58
|
+
/** Recovery bundle: what a resume needs - last rollout + last checkpoint. */
|
|
59
|
+
export declare function resumeBundle(home: string, rec: ProjectRecord): Promise<{
|
|
60
|
+
project: ProjectRecord;
|
|
61
|
+
lastRun: string | null;
|
|
62
|
+
lastCheckpoint: CheckpointRef | null;
|
|
63
|
+
}>;
|
|
64
|
+
/** Interrupt: record the event and pause (paused = resumable per blueprint).
|
|
65
|
+
* The actual abort stays with the caller's AbortSignal. */
|
|
66
|
+
export declare function interruptProject(home: string, rec: ProjectRecord, reason?: string): Promise<ProjectRecord>;
|
|
67
|
+
export declare function releaseProject(home: string, rec: ProjectRecord, version: string, notes?: string): Promise<void>;
|
package/dist/project.js
ADDED
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @hmharness/agent - project runtime (V2 M8, ADR-0002)
|
|
3
|
+
* The Project entity from the V2 blueprint: one record per project workspace
|
|
4
|
+
* binding state machine, checkpoints, decisions, run continuation, releases.
|
|
5
|
+
*
|
|
6
|
+
* Safety posture (the whole design bends around it):
|
|
7
|
+
* - Checkpoints are git PLUMBING snapshots: a temp index (GIT_INDEX_FILE
|
|
8
|
+
* inside the project dir) + read-tree/add/write-tree. Objects land in the
|
|
9
|
+
* repo's .git/objects; the user's index, refs, branches and worktree are
|
|
10
|
+
* never touched - checkpointing does not require the user to commit.
|
|
11
|
+
* - Restore MATERIALIZES a checkpoint into a fresh sandbox copy (git archive
|
|
12
|
+
* + tar). Recovery never mutates the user's tree; there is no code path
|
|
13
|
+
* here that resets anything in the real workspace.
|
|
14
|
+
* - Run continuation rides the 0.8.2 rollout append semantics: attachRun
|
|
15
|
+
* remembers the session id; the caller resumes with loadTranscript +
|
|
16
|
+
* runAgentTask({ sessionId }).
|
|
17
|
+
*/
|
|
18
|
+
import { appendFile, cp, mkdir, mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises';
|
|
19
|
+
import { execFile as execCb } from 'node:child_process';
|
|
20
|
+
import { tmpdir } from 'node:os';
|
|
21
|
+
import { join, resolve } from 'node:path';
|
|
22
|
+
import { sandbox } from '@hmharness/sandbox';
|
|
23
|
+
/** blueprint lifecycle: created→active→paused(=resumable)→completed→archived */
|
|
24
|
+
const TRANSITIONS = {
|
|
25
|
+
created: ['active'],
|
|
26
|
+
active: ['paused', 'completed'],
|
|
27
|
+
paused: ['active', 'completed'],
|
|
28
|
+
completed: ['archived'],
|
|
29
|
+
archived: [],
|
|
30
|
+
};
|
|
31
|
+
/* ---------------- git plumbing (checkpoint side) ---------------- */
|
|
32
|
+
const GIT_CANDIDATES = ['git', 'C:\\Program Files\\Git\\cmd\\git.exe', 'C:\\Program Files (x86)\\Git\\cmd\\git.exe'];
|
|
33
|
+
let gitBin;
|
|
34
|
+
function run(exe, args, opts) {
|
|
35
|
+
return new Promise((res) => {
|
|
36
|
+
execCb(exe, args, { cwd: opts.cwd, env: { ...process.env, ...opts.env }, timeout: opts.timeoutMs ?? 60_000, windowsHide: true, maxBuffer: 32 * 1024 * 1024 }, (err, stdout, stderr) => {
|
|
37
|
+
if (!err) {
|
|
38
|
+
res({ stdout: String(stdout ?? ''), stderr: String(stderr ?? ''), code: 0, launchFailed: false });
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
const e = err;
|
|
42
|
+
// ENOENT-style launch failure (string code) -> try next candidate;
|
|
43
|
+
// a real non-zero exit (numeric code) is a git answer, not a failure to launch
|
|
44
|
+
res({ stdout: String(stdout ?? ''), stderr: String(stderr ?? ''), code: typeof e.code === 'number' ? e.code : -1, launchFailed: typeof e.code !== 'number' });
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
async function git(args, opts) {
|
|
49
|
+
const candidates = [...(gitBin ? [gitBin] : []), ...GIT_CANDIDATES];
|
|
50
|
+
let last = { stdout: '', stderr: 'git not found', code: -1, launchFailed: true };
|
|
51
|
+
for (const exe of candidates) {
|
|
52
|
+
last = await run(exe, args, opts);
|
|
53
|
+
if (!last.launchFailed) {
|
|
54
|
+
gitBin = exe;
|
|
55
|
+
return last;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
throw new Error(last.stderr || 'git not found');
|
|
59
|
+
}
|
|
60
|
+
async function isGitRepo(workspace) {
|
|
61
|
+
try {
|
|
62
|
+
const r = await git(['rev-parse', '--is-inside-work-tree'], { cwd: workspace });
|
|
63
|
+
return r.code === 0 && r.stdout.trim() === 'true';
|
|
64
|
+
}
|
|
65
|
+
catch {
|
|
66
|
+
return false;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
/* ---------------- persistence ---------------- */
|
|
70
|
+
function projRoot(home) { return join(home, 'projects'); }
|
|
71
|
+
function projDir(home, id) { return join(projRoot(home), id); }
|
|
72
|
+
function projFile(home, id) { return join(projDir(home, id), 'project.json'); }
|
|
73
|
+
async function save(home, rec) {
|
|
74
|
+
rec.updatedAt = new Date().toISOString();
|
|
75
|
+
await mkdir(projDir(home, rec.projectId), { recursive: true });
|
|
76
|
+
await writeFile(projFile(home, rec.projectId), JSON.stringify(rec, null, 2) + '\n', 'utf8');
|
|
77
|
+
}
|
|
78
|
+
async function decide(home, rec, kind, summary, ref) {
|
|
79
|
+
const entry = { time: new Date().toISOString(), kind, summary, ...(ref ? { ref } : {}) };
|
|
80
|
+
rec.decisions.push(entry);
|
|
81
|
+
try {
|
|
82
|
+
await appendFile(join(projDir(home, rec.projectId), 'decisions.jsonl'), JSON.stringify(entry) + '\n', 'utf8');
|
|
83
|
+
}
|
|
84
|
+
catch { /* mirror is best-effort */ }
|
|
85
|
+
}
|
|
86
|
+
/* ---------------- lifecycle ---------------- */
|
|
87
|
+
export function newProjectId() {
|
|
88
|
+
return `proj_${new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19)}_${Math.random().toString(36).slice(2, 8)}`;
|
|
89
|
+
}
|
|
90
|
+
export async function createProject(home, workspace, name) {
|
|
91
|
+
const rec = {
|
|
92
|
+
projectId: newProjectId(),
|
|
93
|
+
name,
|
|
94
|
+
workspace: resolve(workspace),
|
|
95
|
+
state: 'created',
|
|
96
|
+
createdAt: new Date().toISOString(),
|
|
97
|
+
updatedAt: new Date().toISOString(),
|
|
98
|
+
checkpoints: [],
|
|
99
|
+
tasks: [],
|
|
100
|
+
decisions: [],
|
|
101
|
+
memory: resolve(workspace),
|
|
102
|
+
skills: [],
|
|
103
|
+
runs: [],
|
|
104
|
+
benchmarks: [],
|
|
105
|
+
releases: [],
|
|
106
|
+
};
|
|
107
|
+
await decide(home, rec, 'state', `project created for ${rec.workspace}`);
|
|
108
|
+
await save(home, rec);
|
|
109
|
+
return rec;
|
|
110
|
+
}
|
|
111
|
+
export async function listProjects(home) {
|
|
112
|
+
let ids = [];
|
|
113
|
+
try {
|
|
114
|
+
ids = (await readdir(projRoot(home))).filter((d) => d.startsWith('proj_'));
|
|
115
|
+
}
|
|
116
|
+
catch {
|
|
117
|
+
return [];
|
|
118
|
+
}
|
|
119
|
+
const out = [];
|
|
120
|
+
for (const id of ids) {
|
|
121
|
+
try {
|
|
122
|
+
out.push(JSON.parse(await readFile(projFile(home, id), 'utf8')));
|
|
123
|
+
}
|
|
124
|
+
catch { /* skip torn */ }
|
|
125
|
+
}
|
|
126
|
+
return out.sort((a, b) => (a.updatedAt < b.updatedAt ? 1 : -1));
|
|
127
|
+
}
|
|
128
|
+
export async function loadProject(home, projectId) {
|
|
129
|
+
try {
|
|
130
|
+
return JSON.parse(await readFile(projFile(home, projectId), 'utf8'));
|
|
131
|
+
}
|
|
132
|
+
catch {
|
|
133
|
+
return null;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
function samePath(a, b) {
|
|
137
|
+
return resolve(a).replace(/\\/g, '/').toLowerCase().replace(/\/+$/, '') === resolve(b).replace(/\\/g, '/').toLowerCase().replace(/\/+$/, '');
|
|
138
|
+
}
|
|
139
|
+
/** Find the newest project bound to this workspace (null if none). */
|
|
140
|
+
export async function findProject(home, workspace) {
|
|
141
|
+
const all = await listProjects(home);
|
|
142
|
+
return all.find((p) => samePath(p.workspace, workspace) && p.state !== 'archived') ?? null;
|
|
143
|
+
}
|
|
144
|
+
/** find-or-create + auto-activate (the default entry point for CLIs). */
|
|
145
|
+
export async function projectFor(home, workspace, name) {
|
|
146
|
+
const found = await findProject(home, workspace);
|
|
147
|
+
if (found) {
|
|
148
|
+
if (found.state === 'created')
|
|
149
|
+
return transitionProject(home, found, 'active');
|
|
150
|
+
return found;
|
|
151
|
+
}
|
|
152
|
+
const rec = await createProject(home, workspace, name);
|
|
153
|
+
return transitionProject(home, rec, 'active');
|
|
154
|
+
}
|
|
155
|
+
export async function transitionProject(home, rec, next) {
|
|
156
|
+
const allowed = TRANSITIONS[rec.state] ?? [];
|
|
157
|
+
if (!allowed.includes(next))
|
|
158
|
+
throw new Error(`illegal transition ${rec.state} -> ${next} (allowed: ${allowed.join(', ') || 'none'})`);
|
|
159
|
+
const from = rec.state;
|
|
160
|
+
rec.state = next;
|
|
161
|
+
await decide(home, rec, 'state', `${from} -> ${next}`);
|
|
162
|
+
await save(home, rec);
|
|
163
|
+
return rec;
|
|
164
|
+
}
|
|
165
|
+
/* ---------------- checkpoints ---------------- */
|
|
166
|
+
const COPY_SKIP = new Set(['node_modules', '.git', 'dist', 'build', '.hvigor', '.idea']);
|
|
167
|
+
/** Byte-exact snapshot via git plumbing (user index/refs/worktree untouched);
|
|
168
|
+
* directory-copy fallback for non-git workspaces. */
|
|
169
|
+
export async function checkpointProject(home, rec, label) {
|
|
170
|
+
if (rec.state === 'archived')
|
|
171
|
+
throw new Error('archived projects cannot take checkpoints');
|
|
172
|
+
let ref;
|
|
173
|
+
if (await isGitRepo(rec.workspace)) {
|
|
174
|
+
const indexFile = join(projDir(home, rec.projectId), `index-${Date.now()}`);
|
|
175
|
+
await mkdir(projDir(home, rec.projectId), { recursive: true });
|
|
176
|
+
const env = { GIT_INDEX_FILE: indexFile };
|
|
177
|
+
// seed the temp index from HEAD (no commits yet -> start empty), then
|
|
178
|
+
// overlay the whole worktree; write-tree freezes it as an object
|
|
179
|
+
await git(['read-tree', 'HEAD'], { cwd: rec.workspace, env, timeoutMs: 30_000 });
|
|
180
|
+
await git(['add', '-A', '--', '.'], { cwd: rec.workspace, env, timeoutMs: 120_000 });
|
|
181
|
+
const tree = (await git(['write-tree'], { cwd: rec.workspace, env })).stdout.trim();
|
|
182
|
+
// workspace may sit inside a subdirectory of the repo - snapshot THE
|
|
183
|
+
// SUBTREE (what the project actually owns), not the whole monorepo
|
|
184
|
+
const prefix = (await git(['rev-parse', '--show-prefix'], { cwd: rec.workspace })).stdout.trim().replace(/\\/g, '/');
|
|
185
|
+
const subTree = prefix ? (await git(['rev-parse', `${tree}:${prefix}`], { cwd: rec.workspace })).stdout.trim() : tree;
|
|
186
|
+
if (!/^[0-9a-f]{40}$/.test(subTree))
|
|
187
|
+
throw new Error(`checkpoint subtree resolve failed (prefix "${prefix}")`);
|
|
188
|
+
const files = (await git(['ls-tree', '-r', '--name-only', subTree], { cwd: rec.workspace })).stdout.split('\n').filter(Boolean).length;
|
|
189
|
+
await rm(indexFile, { force: true });
|
|
190
|
+
ref = { id: subTree.slice(0, 10), label, tree: subTree, time: new Date().toISOString(), files };
|
|
191
|
+
}
|
|
192
|
+
else {
|
|
193
|
+
const rel = `artifacts/cp-${Date.now().toString(36)}`;
|
|
194
|
+
const dest = join(projDir(home, rec.projectId), rel);
|
|
195
|
+
await mkdir(dest, { recursive: true });
|
|
196
|
+
await cp(rec.workspace, dest, { recursive: true, filter: (src) => !COPY_SKIP.has(src.split(/[\\/]/).pop() ?? '') });
|
|
197
|
+
let files = 0;
|
|
198
|
+
const count = async (d) => {
|
|
199
|
+
for (const e of await readdir(d, { withFileTypes: true })) {
|
|
200
|
+
if (e.isDirectory())
|
|
201
|
+
await count(join(d, e.name));
|
|
202
|
+
else
|
|
203
|
+
files++;
|
|
204
|
+
}
|
|
205
|
+
};
|
|
206
|
+
await count(dest);
|
|
207
|
+
ref = { id: `cp_${rel.slice(-8)}`, label, tree: `copy:${rel}`, time: new Date().toISOString(), files };
|
|
208
|
+
}
|
|
209
|
+
rec.checkpoints.push(ref);
|
|
210
|
+
await decide(home, rec, 'checkpoint', `checkpoint ${ref.id}${label ? ` (${label})` : ''} - ${ref.files} files`, ref.tree);
|
|
211
|
+
await save(home, rec);
|
|
212
|
+
return ref;
|
|
213
|
+
}
|
|
214
|
+
/** Materialize a checkpoint into a fresh sandbox copy for inspection or a
|
|
215
|
+
* recovery run. The user's workspace is never touched. */
|
|
216
|
+
export async function restoreCheckpoint(home, rec, checkpointId) {
|
|
217
|
+
const cpRef = rec.checkpoints.find((c) => c.id === checkpointId || c.tree === checkpointId);
|
|
218
|
+
if (!cpRef)
|
|
219
|
+
throw new Error(`no checkpoint ${checkpointId} in project ${rec.projectId}`);
|
|
220
|
+
const dir = await mkdtemp(join(tmpdir(), 'hmh-proj-restore-'));
|
|
221
|
+
const session = await sandbox.create({ dir });
|
|
222
|
+
if (cpRef.tree.startsWith('copy:')) {
|
|
223
|
+
await cp(join(projDir(home, rec.projectId), cpRef.tree.slice(5)), dir, { recursive: true });
|
|
224
|
+
}
|
|
225
|
+
else {
|
|
226
|
+
const tarFile = join(dir, '..', `cp-${Date.now()}.tar`);
|
|
227
|
+
await git(['archive', '--format=tar', `--output=${tarFile}`, cpRef.tree], { cwd: rec.workspace, timeoutMs: 120_000 });
|
|
228
|
+
await new Promise((res, rej) => {
|
|
229
|
+
execCb('tar', ['-xf', tarFile, '-C', dir], { timeout: 120_000, windowsHide: true }, (err) => err ? rej(err) : res());
|
|
230
|
+
});
|
|
231
|
+
await rm(tarFile, { force: true });
|
|
232
|
+
}
|
|
233
|
+
await decide(home, rec, 'restore', `checkpoint ${cpRef.id} materialized to ${dir} (sandbox copy)`, cpRef.tree);
|
|
234
|
+
await save(home, rec);
|
|
235
|
+
return session;
|
|
236
|
+
}
|
|
237
|
+
/* ---------------- run continuation / interrupt / releases ---------------- */
|
|
238
|
+
/** Remember the rollout a project conversation lives in (0.8.2 append semantics). */
|
|
239
|
+
export async function attachRun(home, rec, sessionId) {
|
|
240
|
+
rec.runs.push({ sessionId, time: new Date().toISOString() });
|
|
241
|
+
await decide(home, rec, 'attach-run', `run ${sessionId}`);
|
|
242
|
+
await save(home, rec);
|
|
243
|
+
}
|
|
244
|
+
/** Recovery bundle: what a resume needs - last rollout + last checkpoint. */
|
|
245
|
+
export async function resumeBundle(home, rec) {
|
|
246
|
+
return {
|
|
247
|
+
project: rec,
|
|
248
|
+
lastRun: rec.runs.length > 0 ? rec.runs[rec.runs.length - 1].sessionId : null,
|
|
249
|
+
lastCheckpoint: rec.checkpoints.length > 0 ? rec.checkpoints[rec.checkpoints.length - 1] : null,
|
|
250
|
+
};
|
|
251
|
+
}
|
|
252
|
+
/** Interrupt: record the event and pause (paused = resumable per blueprint).
|
|
253
|
+
* The actual abort stays with the caller's AbortSignal. */
|
|
254
|
+
export async function interruptProject(home, rec, reason) {
|
|
255
|
+
await decide(home, rec, 'interrupt', `interrupted${reason ? `: ${reason}` : ''}`);
|
|
256
|
+
await save(home, rec);
|
|
257
|
+
if (rec.state === 'active')
|
|
258
|
+
return transitionProject(home, rec, 'paused');
|
|
259
|
+
return rec;
|
|
260
|
+
}
|
|
261
|
+
export async function releaseProject(home, rec, version, notes) {
|
|
262
|
+
rec.releases.push({ version, checkpointId: rec.checkpoints.length > 0 ? rec.checkpoints[rec.checkpoints.length - 1].id : undefined, time: new Date().toISOString(), notes });
|
|
263
|
+
await decide(home, rec, 'release', `release ${version}`);
|
|
264
|
+
await save(home, rec);
|
|
265
|
+
}
|
package/dist/runner.d.ts
CHANGED
|
@@ -93,6 +93,9 @@ export interface AgentTaskOptions {
|
|
|
93
93
|
/** Overrides the terminal gate (web supplies a remote one). */
|
|
94
94
|
approvalAsk?: LoopApproval['ask'];
|
|
95
95
|
resumeMessages?: ChatMessage[];
|
|
96
|
+
/** Continue THIS session's rollout (append) instead of starting a new one -
|
|
97
|
+
* codex Resume semantics: one thread, one JSONL, appended across turns. */
|
|
98
|
+
sessionId?: string;
|
|
96
99
|
events?: RunnerEvents;
|
|
97
100
|
/** AbortSignal: cancels the agent loop at the next turn boundary. */
|
|
98
101
|
signal?: AbortSignal;
|
package/dist/runner.js
CHANGED
|
@@ -229,7 +229,10 @@ export async function runAgentTask(opts) {
|
|
|
229
229
|
const cfg = opts.cfg ?? (await loadConfig());
|
|
230
230
|
const ctx = opts.ctx ?? { cwd: process.cwd(), home: homeDir() };
|
|
231
231
|
const events = opts.events ?? {};
|
|
232
|
-
|
|
232
|
+
// resume = append to the same rollout; a missing/unreadable id falls back
|
|
233
|
+
// to a fresh session so a renamed-away file never breaks the conversation
|
|
234
|
+
const session = (opts.sessionId ? await Session.resume(ctx.home, opts.sessionId) : null)
|
|
235
|
+
?? Session.create(ctx.home, ctx.cwd, cfg.provider.model);
|
|
233
236
|
// V2 M1 flight recorder: every run leaves a typed, replayable trajectory
|
|
234
237
|
// under <home>/runs/<run-id>/. Best-effort by contract - storage failures
|
|
235
238
|
// are swallowed inside the recorder and can never fail the task itself.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hmharness/agent",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.4",
|
|
4
4
|
"description": "hmharness agent execution layer: base tools, system prompt, sub-agent spawn, and the shared task runner that frontends (cli, web) drive.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -15,11 +15,12 @@
|
|
|
15
15
|
"build": "tsc -p tsconfig.build.json"
|
|
16
16
|
},
|
|
17
17
|
"dependencies": {
|
|
18
|
-
"@hmharness/kernel": "0.8.0",
|
|
19
|
-
"@hmharness/observability": "0.7.0",
|
|
20
|
-
"@hmharness/evolution": "0.8.1",
|
|
21
18
|
"@hmharness/domain-harmony": "0.8.0",
|
|
22
|
-
"@hmharness/domain-ops": "0.8.0"
|
|
19
|
+
"@hmharness/domain-ops": "0.8.0",
|
|
20
|
+
"@hmharness/evolution": "0.8.4",
|
|
21
|
+
"@hmharness/kernel": "0.8.2",
|
|
22
|
+
"@hmharness/observability": "0.7.0",
|
|
23
|
+
"@hmharness/sandbox": "0.8.0"
|
|
23
24
|
},
|
|
24
25
|
"files": [
|
|
25
26
|
"dist"
|