@paleo/alcode 0.3.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +50 -5
- package/dist/session-file.d.ts +7 -0
- package/dist/session-file.js +85 -4
- package/package.json +1 -1
- package/templates/guide-run-openclaw.md +1 -0
- package/templates/guide.md +8 -4
package/dist/cli.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { type SessionRecord } from "./session-file.js";
|
|
1
2
|
import { type RunConfig, type RunOutput } from "./run-claude.js";
|
|
2
3
|
export interface MainOptions {
|
|
3
4
|
argv?: string[];
|
|
@@ -7,6 +8,7 @@ export interface MainOptions {
|
|
|
7
8
|
env?: NodeJS.ProcessEnv;
|
|
8
9
|
}
|
|
9
10
|
export declare function main(options?: MainOptions): Promise<number>;
|
|
11
|
+
export declare function checkLaunchGuards(parsed: AlcodeArgs, realCwd: string, records: SessionRecord[]): string | undefined;
|
|
10
12
|
export declare function buildRunConfig(parsed: AlcodeArgs, cwd: string, sessionFilePath: string, env: NodeJS.ProcessEnv): RunConfig;
|
|
11
13
|
export interface AlcodeArgs {
|
|
12
14
|
isNew: boolean;
|
package/dist/cli.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
import { readFileSync } from "node:fs";
|
|
1
|
+
import { readFileSync, realpathSync } from "node:fs";
|
|
2
2
|
import { relative } from "node:path";
|
|
3
3
|
import { parseArgs } from "node:util";
|
|
4
4
|
import { renderGuide } from "./guide.js";
|
|
5
|
-
import { assertPlansGate, resolveSessionFilePath, writeInitialSessionFile, } from "./session-file.js";
|
|
5
|
+
import { assertPlansGate, listSessionRecords, resolveSessionFilePath, writeInitialSessionFile, } from "./session-file.js";
|
|
6
6
|
import { buildPrompt, PROTOCOLS } from "./prompt.js";
|
|
7
7
|
import { runClaude } from "./run-claude.js";
|
|
8
8
|
export async function main(options) {
|
|
@@ -57,9 +57,15 @@ async function runSession(parsed, ctx) {
|
|
|
57
57
|
stderr.write(`${gateError}\n`);
|
|
58
58
|
return 1;
|
|
59
59
|
}
|
|
60
|
+
const realCwd = realpathSync(cwd);
|
|
61
|
+
const guardError = checkLaunchGuards(parsed, realCwd, listSessionRecords(cwd));
|
|
62
|
+
if (guardError) {
|
|
63
|
+
stderr.write(`${guardError}\n`);
|
|
64
|
+
return 1;
|
|
65
|
+
}
|
|
60
66
|
const now = new Date();
|
|
61
67
|
const sessionFilePath = resolveSessionFilePath(cwd, parsed.ticket, now);
|
|
62
|
-
writeInitialSessionFile(sessionFilePath, buildFrontmatter(parsed, now));
|
|
68
|
+
writeInitialSessionFile(sessionFilePath, buildFrontmatter(parsed, now, realCwd));
|
|
63
69
|
stdout.write(`Session file: ${relative(cwd, sessionFilePath)}\n\n`);
|
|
64
70
|
const result = await runClaude(buildRunConfig(parsed, cwd, sessionFilePath, env), stdout);
|
|
65
71
|
if (parsed.isNew && result.sessionId) {
|
|
@@ -67,7 +73,44 @@ async function runSession(parsed, ctx) {
|
|
|
67
73
|
}
|
|
68
74
|
return result.status === "succeeded" ? 0 : 1;
|
|
69
75
|
}
|
|
70
|
-
|
|
76
|
+
// Fail-fast launch guards, run against the (healed) session records before anything is written.
|
|
77
|
+
// Returns the error to print, or `undefined` when the launch may proceed.
|
|
78
|
+
export function checkLaunchGuards(parsed, realCwd, records) {
|
|
79
|
+
if (parsed.resume !== undefined) {
|
|
80
|
+
// A resumed run writes a new session file carrying the same sessionId as the original, so one
|
|
81
|
+
// id can match several records — a running status on any of them blocks.
|
|
82
|
+
const matches = records.filter((r) => r.frontmatter.sessionId === parsed.resume);
|
|
83
|
+
if (matches.length === 0)
|
|
84
|
+
return unknownResumeError(parsed.resume, records);
|
|
85
|
+
const running = matches.find((r) => r.frontmatter.status === "running");
|
|
86
|
+
if (running) {
|
|
87
|
+
return (`Error: session ${parsed.resume} is still running (pid ${running.frontmatter.pid}); ` +
|
|
88
|
+
"wait for it to finish or kill it.");
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
// Protocol runs only: plain messages (answers, questions, plan executions) may run at any time.
|
|
92
|
+
if (parsed.protocol !== undefined) {
|
|
93
|
+
const busy = records.find((r) => r.frontmatter.status === "running" && r.frontmatter.cwd === realCwd);
|
|
94
|
+
if (busy) {
|
|
95
|
+
return (`Error: a protocol run is already active in this worktree (${busy.path}, ` +
|
|
96
|
+
`pid ${busy.frontmatter.pid}); one protocol run at a time per worktree.`);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
function unknownResumeError(resume, records) {
|
|
102
|
+
if (records.length === 0) {
|
|
103
|
+
return `Error: unknown session id ${resume}; no session records exist under .plans/.`;
|
|
104
|
+
}
|
|
105
|
+
const recent = [...records]
|
|
106
|
+
.sort((a, b) => b.frontmatter.startedAt.localeCompare(a.frontmatter.startedAt))
|
|
107
|
+
.slice(0, 5)
|
|
108
|
+
.map(({ frontmatter: f }) => {
|
|
109
|
+
return ` ${f.sessionId ?? "(no id)"} ${f.status} ${f.startedAt} ticket ${f.ticket ?? "-"}`;
|
|
110
|
+
});
|
|
111
|
+
return `Error: unknown session id ${resume}. Known recent sessions:\n${recent.join("\n")}`;
|
|
112
|
+
}
|
|
113
|
+
function buildFrontmatter(parsed, now, realCwd) {
|
|
71
114
|
return {
|
|
72
115
|
status: "running",
|
|
73
116
|
protocol: parsed.protocol ?? null,
|
|
@@ -76,6 +119,8 @@ function buildFrontmatter(parsed, now) {
|
|
|
76
119
|
sessionId: null,
|
|
77
120
|
command: formatCommand(parsed),
|
|
78
121
|
meta: parsed.meta ?? null,
|
|
122
|
+
pid: process.pid,
|
|
123
|
+
cwd: realCwd,
|
|
79
124
|
startedAt: now.toISOString(),
|
|
80
125
|
endedAt: null,
|
|
81
126
|
exitReason: null,
|
|
@@ -171,7 +216,7 @@ export function validateArgs(args) {
|
|
|
171
216
|
}
|
|
172
217
|
return;
|
|
173
218
|
}
|
|
174
|
-
// The ticket becomes a `.plans/<ticket>/
|
|
219
|
+
// The ticket becomes a `.plans/<ticket>/_alcode/…` path segment. Ticket formats vary by
|
|
175
220
|
// consumer repo (numeric here, but e.g. `AB-123` elsewhere), so allow a permissive charset while
|
|
176
221
|
// blocking path separators and `..` traversal that could escape `.plans/`.
|
|
177
222
|
function isPathSafeTicket(ticket) {
|
package/dist/session-file.d.ts
CHANGED
|
@@ -6,6 +6,8 @@ export interface SessionFrontmatter {
|
|
|
6
6
|
sessionId: string | null;
|
|
7
7
|
command: string;
|
|
8
8
|
meta: string | null;
|
|
9
|
+
pid: number | null;
|
|
10
|
+
cwd: string | null;
|
|
9
11
|
startedAt: string;
|
|
10
12
|
endedAt: string | null;
|
|
11
13
|
exitReason: string | null;
|
|
@@ -28,5 +30,10 @@ export interface SessionCompletion {
|
|
|
28
30
|
result: string | undefined;
|
|
29
31
|
}
|
|
30
32
|
export declare function readCompletion(sessionFilePath: string): SessionCompletion;
|
|
33
|
+
export interface SessionRecord {
|
|
34
|
+
path: string;
|
|
35
|
+
frontmatter: SessionFrontmatter;
|
|
36
|
+
}
|
|
37
|
+
export declare function listSessionRecords(cwd: string): SessionRecord[];
|
|
31
38
|
export declare function serializeFrontmatter(frontmatter: SessionFrontmatter): string;
|
|
32
39
|
export declare function parseFrontmatter(block: string): SessionFrontmatter;
|
package/dist/session-file.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
1
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync, } from "node:fs";
|
|
2
2
|
import { dirname, join } from "node:path";
|
|
3
3
|
export const RESULT_MARKER = "\n---- Result ----\n";
|
|
4
4
|
// The CWD must be an AlignFirst-managed project (a `.plans/` dir marks it). Returns an error message
|
|
@@ -10,9 +10,7 @@ export function assertPlansGate(cwd) {
|
|
|
10
10
|
"Run alcode from the root of an AlignFirst-managed project.");
|
|
11
11
|
}
|
|
12
12
|
export function resolveSessionFilePath(cwd, ticket, now, fileExists = existsSync) {
|
|
13
|
-
const dir = ticket
|
|
14
|
-
? join(cwd, ".plans", ticket, "coding-sessions")
|
|
15
|
-
: join(cwd, ".plans", "_coding-sessions");
|
|
13
|
+
const dir = ticket ? join(cwd, ".plans", ticket, "_alcode") : join(cwd, ".plans", "_alcode");
|
|
16
14
|
const stamp = formatStamp(now);
|
|
17
15
|
let candidate = join(dir, `${stamp}.md`);
|
|
18
16
|
let suffix = 2;
|
|
@@ -59,6 +57,79 @@ export function readCompletion(sessionFilePath) {
|
|
|
59
57
|
const result = markerIndex === -1 ? undefined : content.slice(markerIndex + RESULT_MARKER.length).trim();
|
|
60
58
|
return { frontmatter, result };
|
|
61
59
|
}
|
|
60
|
+
// Lists every session record for a project root: `.plans/_alcode/*.md` plus each ticket's
|
|
61
|
+
// `.plans/<ticket>/_alcode/*.md`. The session files are the registry — no separate registry file.
|
|
62
|
+
// Self-healing: a `running` record whose pid is gone is a stale leftover from an interrupted run;
|
|
63
|
+
// it gets sealed in passing so the launch guards never block on dead state. The returned records
|
|
64
|
+
// reflect the post-healing state.
|
|
65
|
+
export function listSessionRecords(cwd) {
|
|
66
|
+
const plansDir = join(cwd, ".plans");
|
|
67
|
+
const sessionDirs = [join(plansDir, "_alcode")];
|
|
68
|
+
for (const entry of readEntries(plansDir)) {
|
|
69
|
+
if (entry.isDirectory() && entry.name !== "_alcode") {
|
|
70
|
+
sessionDirs.push(join(plansDir, entry.name, "_alcode"));
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
const records = [];
|
|
74
|
+
for (const dir of sessionDirs) {
|
|
75
|
+
for (const entry of readEntries(dir)) {
|
|
76
|
+
if (!entry.isFile() || !entry.name.endsWith(".md"))
|
|
77
|
+
continue;
|
|
78
|
+
const path = join(dir, entry.name);
|
|
79
|
+
const frontmatter = readFrontmatterOrSkip(path);
|
|
80
|
+
if (frontmatter)
|
|
81
|
+
records.push({ path, frontmatter: sealIfStale(path, frontmatter) });
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
return records;
|
|
85
|
+
}
|
|
86
|
+
function readEntries(dir) {
|
|
87
|
+
try {
|
|
88
|
+
return readdirSync(dir, { withFileTypes: true });
|
|
89
|
+
}
|
|
90
|
+
catch {
|
|
91
|
+
return [];
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
// A malformed file (e.g. missing its frontmatter block) must not block every future launch.
|
|
95
|
+
function readFrontmatterOrSkip(path) {
|
|
96
|
+
try {
|
|
97
|
+
return readCompletion(path).frontmatter;
|
|
98
|
+
}
|
|
99
|
+
catch {
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
function sealIfStale(path, frontmatter) {
|
|
104
|
+
if (frontmatter.status !== "running")
|
|
105
|
+
return frontmatter;
|
|
106
|
+
if (frontmatter.pid !== null && isPidAlive(frontmatter.pid))
|
|
107
|
+
return frontmatter;
|
|
108
|
+
const update = {
|
|
109
|
+
status: "failed",
|
|
110
|
+
endedAt: new Date().toISOString(),
|
|
111
|
+
exitReason: "terminated",
|
|
112
|
+
sessionId: frontmatter.sessionId,
|
|
113
|
+
result: `Sealed as interrupted: process ${frontmatter.pid ?? "(unknown)"} is gone.`,
|
|
114
|
+
};
|
|
115
|
+
applyCompletion(path, update);
|
|
116
|
+
return {
|
|
117
|
+
...frontmatter,
|
|
118
|
+
status: update.status,
|
|
119
|
+
endedAt: update.endedAt,
|
|
120
|
+
exitReason: update.exitReason,
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
function isPidAlive(pid) {
|
|
124
|
+
try {
|
|
125
|
+
process.kill(pid, 0);
|
|
126
|
+
return true;
|
|
127
|
+
}
|
|
128
|
+
catch (err) {
|
|
129
|
+
// EPERM: the process exists but belongs to another user — alive. ESRCH (or anything else): dead.
|
|
130
|
+
return err.code === "EPERM";
|
|
131
|
+
}
|
|
132
|
+
}
|
|
62
133
|
// --- Frontmatter serialization (dependency-free, round-trips with parseFrontmatter) ---
|
|
63
134
|
export function serializeFrontmatter(frontmatter) {
|
|
64
135
|
const lines = Object.entries(frontmatter).map(([key, value]) => `${key}: ${serializeValue(value)}`);
|
|
@@ -67,6 +138,8 @@ export function serializeFrontmatter(frontmatter) {
|
|
|
67
138
|
function serializeValue(value) {
|
|
68
139
|
if (value === null)
|
|
69
140
|
return "";
|
|
141
|
+
if (typeof value === "number")
|
|
142
|
+
return String(value);
|
|
70
143
|
return needsQuote(value) ? JSON.stringify(value) : value;
|
|
71
144
|
}
|
|
72
145
|
function needsQuote(value) {
|
|
@@ -95,11 +168,19 @@ export function parseFrontmatter(block) {
|
|
|
95
168
|
sessionId: map.sessionId ?? null,
|
|
96
169
|
command: map.command ?? "",
|
|
97
170
|
meta: map.meta ?? null,
|
|
171
|
+
pid: parsePid(map.pid),
|
|
172
|
+
cwd: map.cwd ?? null,
|
|
98
173
|
startedAt: map.startedAt ?? "",
|
|
99
174
|
endedAt: map.endedAt ?? null,
|
|
100
175
|
exitReason: map.exitReason ?? null,
|
|
101
176
|
};
|
|
102
177
|
}
|
|
178
|
+
function parsePid(raw) {
|
|
179
|
+
if (raw === null || raw === undefined)
|
|
180
|
+
return null;
|
|
181
|
+
const pid = Number(raw);
|
|
182
|
+
return Number.isFinite(pid) ? pid : null;
|
|
183
|
+
}
|
|
103
184
|
function parseValue(raw) {
|
|
104
185
|
if (raw === "")
|
|
105
186
|
return null;
|
package/package.json
CHANGED
|
@@ -2,4 +2,5 @@ Under OpenClaw, background it through the `exec` tool:
|
|
|
2
2
|
|
|
3
3
|
- Pass `background: true` and `timeout: 0` (no kill timer). Never rely on the auto-yield or a finite timeout.
|
|
4
4
|
- Set the exec `workdir` to the project root as an **absolute** path (`~` is not expanded there), or `cd` into the project inside the command itself.
|
|
5
|
+
- The session-file path comes from the run's first stdout line (`Session file: …`), available via `process log <id>`. The stamp in the file name is the run's start time; it cannot be derived from the clock.
|
|
5
6
|
- If the outcome must be reported into a thread **you created yourself** via `message` `action: "thread-create"`, add `--meta "<THREAD_ID>"` with that thread's `chat_id`: the completion wake's plain reply goes to this session's default surface (the channel), and only the session file's `meta` can point your report back at the thread (via `message` `action: "thread-reply"`). In every other case omit `--meta` — platform-threaded replies and thread-bound sessions already report in the right place.
|
package/templates/guide.md
CHANGED
|
@@ -16,17 +16,19 @@ Coding runs can be long (several hours is fine): **always run `alcode` as a back
|
|
|
16
16
|
|
|
17
17
|
As soon as the run is backgrounded, tell the user the coding agent is now working in the background and that you will report back when it finishes (e.g. *"Le coding agent tourne en arrière-plan — je te préviens dès que c'est terminé."*). Then go available. Do **not** poll.
|
|
18
18
|
|
|
19
|
-
Every run writes a session file under `.plans/`: `.plans/<ticket>/
|
|
19
|
+
Every run writes a session file under `.plans/`: `.plans/<ticket>/_alcode/<stamp>.md`, or `.plans/_alcode/<stamp>.md` without a ticket. This file is the durable record of the run. Its frontmatter carries `status` (`running` → `succeeded`/`failed`) and the `sessionId`, and the `---- Result ----` block holds the outcome.
|
|
20
|
+
|
|
21
|
+
**One protocol run at a time per workspace** — protocol runs share the working tree. Finish (or kill) the current protocol run before launching or resuming another. Plain messages (answers, questions) can be sent at any time.
|
|
20
22
|
|
|
21
23
|
## After a background run completes
|
|
22
24
|
|
|
23
25
|
{{WAKE}}
|
|
24
26
|
|
|
25
|
-
1. **Read the run's session file** (the path `alcode` printed on its first line, under `
|
|
26
|
-
2. **Report the outcome to the user** where the work was requested. Send one concise message: succeeded or failed, plus a one-line summary of the result for the audience. If the frontmatter `meta` carries a destination (e.g. a thread target), route this report there — a plain reply from the wake turn goes to the session's default surface, which may not be where the work was requested.
|
|
27
|
+
1. **Read the run's session file** (the path `alcode` printed on its first line, under `_alcode/`). Its frontmatter holds `status` (`succeeded` / `failed`) and the session id; the `---- Result ----` block holds the outcome. If you set `meta` at launch, it is there too.
|
|
28
|
+
2. **Report the outcome to the user** where the work was requested. Send one concise message: succeeded or failed, plus a one-line summary of the result for the audience. If the frontmatter `meta` carries a destination (e.g. a thread target), route this report there — a plain reply from the wake turn goes to the session's default surface, which may not be where the work was requested. When the report went out through the `message` tool, end the wake turn with a final answer of exactly `NO_REPLY` — any other final text streams to the default surface as a stray duplicate. `NO_REPLY` is the only silent ending; never improvise another token (`HEARTBEAT_OK` posts as literal text).
|
|
27
29
|
3. Do **not** re-verify the repo, re-run the coding agent, fetch/merge branches, or inspect `git`. The coding agent already did the work and the session file is authoritative. Relay its outcome, nothing more.
|
|
28
30
|
|
|
29
|
-
If the session file says the run failed, report that plainly and propose the next step; don't silently retry.
|
|
31
|
+
If the session file says the run failed, report that plainly and propose the next step; don't silently retry. When a session turns bad, keep everything in place — session files, directories, and records are the durable audit trail; never delete them.
|
|
30
32
|
|
|
31
33
|
## CLI reference
|
|
32
34
|
|
|
@@ -61,6 +63,8 @@ When asking a question (not executing a plan) with `--new` and no protocol, the
|
|
|
61
63
|
|
|
62
64
|
The default workflow. Always start with it, except for very insignificant tasks.
|
|
63
65
|
|
|
66
|
+
For large work, do not rush. Decompose it yourself only when the concerns are truly distinct; otherwise write one big spec, iterate on discussing it with the agent, then translate it into one or several plans.
|
|
67
|
+
|
|
64
68
|
1. **Spec** — `alcode --new --protocol spec --ticket AB-123 --message "Feature description"`. The agent investigates and asks questions; save the session id. Iterate until it writes the spec file.
|
|
65
69
|
2. **Plan** — `alcode --resume <sessionId> --protocol plan`. The agent writes the plan file.
|
|
66
70
|
3. **Execute** — `alcode --new --message "Execute the plan: \`.plans/AB-123/A2-plan.md\`"`. The agent implements and writes a summary file.
|