@nettee/beacon 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/README.md +63 -4
- package/dist/config/global.js +13 -2
- package/dist/run/create-pi-orchestrator.js +1 -0
- package/dist/run/orchestrator.js +35 -4
- package/dist/runtime/pi-rpc.js +29 -1
- package/dist/state/trigger-store.js +21 -1
- package/examples/config.yaml +1 -0
- package/package.json +2 -1
package/README.md
CHANGED
|
@@ -61,6 +61,20 @@ chmod 700 /Users/USERNAME/.beacon
|
|
|
61
61
|
chmod 600 /Users/USERNAME/.beacon/secrets.json
|
|
62
62
|
```
|
|
63
63
|
|
|
64
|
+
Configure a dedicated absolute Pi session root in `config.yaml`:
|
|
65
|
+
|
|
66
|
+
```yaml
|
|
67
|
+
pi:
|
|
68
|
+
executable: /ABSOLUTE/PATH/TO/pi
|
|
69
|
+
coding_agent_directory: /Users/USERNAME/.pi/agent
|
|
70
|
+
session_directory: /Users/USERNAME/.beacon/sessions
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
`pi.session_directory` is optional for compatibility with configurations
|
|
74
|
+
created by Beacon 0.1.2 and earlier; when omitted it defaults to `sessions/`
|
|
75
|
+
beside `config.yaml`. When configured, it must be absolute. Beacon creates
|
|
76
|
+
per-Run directories with mode `0700` when Pi starts.
|
|
77
|
+
|
|
64
78
|
Configuration is strict: unknown YAML/JSON fields, YAML aliases or warnings, missing paths, duplicate Schedule IDs, invalid timezones/cron expressions, unsafe Prompt paths, permissive secret permissions, and missing Profile credentials all fail startup. Beacon validates all Profiles before opening a Feishu connection.
|
|
65
79
|
|
|
66
80
|
Each Schedule uses a five-field cron expression and an IANA timezone. Its `delivery.chat_id` may identify either a direct chat or a group chat; Beacon deliberately does not infer or fall back to another destination. A newly discovered Schedule starts at the current time. After sleep or restart, overdue occurrences are reconciled and coalesced to the most recent one.
|
|
@@ -85,6 +99,30 @@ printf '%s\n' 'Summarize the workspace status.' | \
|
|
|
85
99
|
|
|
86
100
|
Per-Profile state lives below `profiles/<profile-id>/state/`. Trigger claims, normalized inputs, Run state, Final Outcomes, Delivery state, and Schedule cursors use durable JSON snapshots. Records do not expire, and Beacon has no automatic cleanup task. Manually deleting state also deletes its deduplication memory.
|
|
87
101
|
|
|
102
|
+
Every new business Run also owns a permanent Pi session. Its Run record stores
|
|
103
|
+
both `sessionId` and `sessionPath`; the session ID is exactly the Beacon
|
|
104
|
+
`runId`, and the path is the dedicated directory
|
|
105
|
+
`<pi.session_directory>/<profile-id>/<runId>/`. Pi receives that mapping via
|
|
106
|
+
`--session-dir`, `--session-id`, and a readable `Beacon <profile-id> <runId>`
|
|
107
|
+
name. The directory contains the Pi JSONL session file and is never cleaned up
|
|
108
|
+
by Beacon. A queued Run keeps the same mapping after restart. Queued records
|
|
109
|
+
written by Beacon 0.1.2 or earlier are assigned the same deterministic mapping
|
|
110
|
+
when recovered. Historical completed records remain readable and may omit the
|
|
111
|
+
two session fields because those Runs were originally ephemeral.
|
|
112
|
+
|
|
113
|
+
To locate a session from a reported `run_id`, first find its durable Run
|
|
114
|
+
record, then inspect or export the sole JSONL file in `sessionPath`:
|
|
115
|
+
|
|
116
|
+
```sh
|
|
117
|
+
rg -l '"runId": "run_REPORTED_ID"' /Users/USERNAME/.beacon/profiles/*/state/triggers/*/record.json
|
|
118
|
+
jq '.run | {runId, sessionId, sessionPath, state}' /ABSOLUTE/PATH/TO/record.json
|
|
119
|
+
find /ABSOLUTE/SESSION/PATH -maxdepth 1 -name '*.jsonl' -print
|
|
120
|
+
pi --export /ABSOLUTE/SESSION/PATH/TIMESTAMP_run_REPORTED_ID.jsonl run.html
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
`beacon doctor` keeps using `--no-session`, so its Pi smoke tests do not create
|
|
124
|
+
diagnostic session files.
|
|
125
|
+
|
|
88
126
|
Duplicate Feishu events and duplicate Schedule occurrences do not start a second Run. Active Runs interrupted by restart fail rather than rerun. Pending Delivery can resume, while a Delivery interrupted after its external call began fails without resending. Run and Delivery success are recorded independently. Beacon does not automatically retry either one.
|
|
89
127
|
|
|
90
128
|
Secrets and ephemeral Run Capability tokens are excluded from persisted records and from the Pi environment except for the one Run-scoped Outcome capability.
|
|
@@ -131,16 +169,37 @@ installed `beacon` binary without using the source tree.
|
|
|
131
169
|
`pnpm e2e:message` sends one synthetic Feishu direct message through an
|
|
132
170
|
in-memory Gateway, the production message pipeline, and a real Pi model. The
|
|
133
171
|
same in-memory Gateway captures the quoted reply without contacting Feishu. It defaults to
|
|
134
|
-
`openai-codex/gpt-5.
|
|
172
|
+
`openai-codex/gpt-5.6-luna:low`; override the runtime with
|
|
135
173
|
`BEACON_E2E_MESSAGE_PROVIDER`, `BEACON_E2E_MESSAGE_MODEL`,
|
|
136
174
|
`BEACON_E2E_MESSAGE_PI_EXECUTABLE`, or
|
|
137
175
|
`BEACON_E2E_MESSAGE_PI_CODING_AGENT_DIRECTORY`.
|
|
138
176
|
|
|
139
177
|
## Publishing
|
|
140
178
|
|
|
141
|
-
Choose the next semantic version explicitly
|
|
142
|
-
|
|
143
|
-
|
|
179
|
+
Choose the next semantic version explicitly when the release requires a major
|
|
180
|
+
or minor bump, or when you want to override the automatic patch decision:
|
|
181
|
+
|
|
182
|
+
```sh
|
|
183
|
+
pnpm bump-version major
|
|
184
|
+
pnpm bump-version minor
|
|
185
|
+
pnpm bump-version patch
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
For an in-repository pull request that changes production CLI inputs under
|
|
189
|
+
`src/`, `package.json`, `pnpm-lock.yaml`, or `tsconfig.json` without changing
|
|
190
|
+
the package version, CI automatically commits a patch bump to the PR branch.
|
|
191
|
+
Test-only and documentation changes do not trigger a release. An explicit
|
|
192
|
+
version change takes precedence over the automatic patch bump, and CI rejects
|
|
193
|
+
a changed version that is not greater than the base branch version.
|
|
194
|
+
|
|
195
|
+
The bump job accepts only branches in this repository, not pull requests from
|
|
196
|
+
forks. It uses the job-scoped `github.token` to push the version commit, then
|
|
197
|
+
dispatches a fresh CI run for the new commit. This avoids a long-lived personal
|
|
198
|
+
access token while working around GitHub's suppression of workflows caused by
|
|
199
|
+
bot pushes.
|
|
200
|
+
|
|
201
|
+
Submit the release candidate through the normal pull request checks. After the
|
|
202
|
+
initial package bootstrap, each push to `main` runs
|
|
144
203
|
the npm publish workflow. It verifies the source and packaged installation,
|
|
145
204
|
publishes a version that is not yet present, and explicitly skips a version
|
|
146
205
|
that already exists.
|
package/dist/config/global.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { constants } from "node:fs";
|
|
2
2
|
import { access, realpath, stat } from "node:fs/promises";
|
|
3
|
-
import { dirname, isAbsolute, resolve } from "node:path";
|
|
3
|
+
import { dirname, isAbsolute, join, resolve } from "node:path";
|
|
4
4
|
import { z } from "zod";
|
|
5
5
|
import { parseStrictYaml } from "./yaml.js";
|
|
6
6
|
const positiveInteger = z.number().int().positive().safe();
|
|
@@ -12,6 +12,7 @@ const globalDocumentSchema = z
|
|
|
12
12
|
.object({
|
|
13
13
|
executable: z.string().min(1),
|
|
14
14
|
coding_agent_directory: z.string().min(1),
|
|
15
|
+
session_directory: z.string().min(1).optional(),
|
|
15
16
|
})
|
|
16
17
|
.strict(),
|
|
17
18
|
runs: z
|
|
@@ -63,6 +64,10 @@ export async function loadGlobalConfig(path) {
|
|
|
63
64
|
if (!isAbsolute(document.pi.coding_agent_directory)) {
|
|
64
65
|
throw new Error("Pi coding agent directory must be absolute");
|
|
65
66
|
}
|
|
67
|
+
if (document.pi.session_directory !== undefined &&
|
|
68
|
+
!isAbsolute(document.pi.session_directory)) {
|
|
69
|
+
throw new Error("Pi session directory path must be absolute");
|
|
70
|
+
}
|
|
66
71
|
const executable = await assertFile(document.pi.executable, true);
|
|
67
72
|
const codingAgentDirectory = await assertDirectory(document.pi.coding_agent_directory);
|
|
68
73
|
return {
|
|
@@ -70,7 +75,13 @@ export async function loadGlobalConfig(path) {
|
|
|
70
75
|
homeDirectory,
|
|
71
76
|
profilesDirectory,
|
|
72
77
|
secretsPath: resolve(homeDirectory, "secrets.json"),
|
|
73
|
-
pi: {
|
|
78
|
+
pi: {
|
|
79
|
+
executable,
|
|
80
|
+
codingAgentDirectory,
|
|
81
|
+
// Keep version 1 configurations valid while moving sessions out of Pi's
|
|
82
|
+
// workspace-derived default hierarchy.
|
|
83
|
+
sessionDirectory: document.pi.session_directory ?? join(homeDirectory, "sessions"),
|
|
84
|
+
},
|
|
74
85
|
runs: {
|
|
75
86
|
maxConcurrent: document.runs.max_concurrent,
|
|
76
87
|
maxQueued: document.runs.max_queued,
|
|
@@ -8,6 +8,7 @@ export function createPiRunOrchestrator(options) {
|
|
|
8
8
|
queue: options.queue,
|
|
9
9
|
outcomes: options.outcomes,
|
|
10
10
|
beaconCliPath: fileURLToPath(new URL("../../dist/cli.js", import.meta.url)),
|
|
11
|
+
sessionDirectory: options.config.pi.sessionDirectory,
|
|
11
12
|
runAgent: (request) => runPiAgent(request, {
|
|
12
13
|
executable: options.config.pi.executable,
|
|
13
14
|
timeoutMs: options.config.runs.timeoutSeconds * 1_000,
|
package/dist/run/orchestrator.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
+
import { isAbsolute, join } from "node:path";
|
|
2
3
|
import { PiRuntimeError } from "../runtime/pi-rpc.js";
|
|
3
4
|
function promptFor(input) {
|
|
4
5
|
if (input.kind === "manual") {
|
|
@@ -42,6 +43,9 @@ export class RunOrchestrator {
|
|
|
42
43
|
id;
|
|
43
44
|
constructor(options) {
|
|
44
45
|
this.options = options;
|
|
46
|
+
if (!isAbsolute(options.sessionDirectory)) {
|
|
47
|
+
throw new Error("Pi session directory must be absolute");
|
|
48
|
+
}
|
|
45
49
|
this.now = options.now ?? (() => new Date());
|
|
46
50
|
this.id = options.id ?? randomUUID;
|
|
47
51
|
}
|
|
@@ -52,6 +56,8 @@ export class RunOrchestrator {
|
|
|
52
56
|
const timestamp = this.timestamp();
|
|
53
57
|
return {
|
|
54
58
|
runId,
|
|
59
|
+
sessionId: runId,
|
|
60
|
+
sessionPath: join(this.options.sessionDirectory, this.options.profile.id, runId),
|
|
55
61
|
state,
|
|
56
62
|
queuedAt: timestamp,
|
|
57
63
|
...(state === "failed" ? { finishedAt: timestamp } : {}),
|
|
@@ -136,10 +142,30 @@ export class RunOrchestrator {
|
|
|
136
142
|
await this.deliver(triggerKey);
|
|
137
143
|
}
|
|
138
144
|
async execute(triggerKey, input, runId) {
|
|
139
|
-
await this.options.store.update(triggerKey, (current) =>
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
145
|
+
const record = await this.options.store.update(triggerKey, (current) => {
|
|
146
|
+
if (!current.run) {
|
|
147
|
+
throw new Error(`Cannot execute Trigger ${triggerKey} without a Run`);
|
|
148
|
+
}
|
|
149
|
+
const session = current.run.sessionId && current.run.sessionPath
|
|
150
|
+
? {
|
|
151
|
+
sessionId: current.run.sessionId,
|
|
152
|
+
sessionPath: current.run.sessionPath,
|
|
153
|
+
}
|
|
154
|
+
: {
|
|
155
|
+
// Migration for queued records written by Beacon <= 0.1.2.
|
|
156
|
+
sessionId: current.run.runId,
|
|
157
|
+
sessionPath: join(this.options.sessionDirectory, this.options.profile.id, current.run.runId),
|
|
158
|
+
};
|
|
159
|
+
return {
|
|
160
|
+
...current,
|
|
161
|
+
run: {
|
|
162
|
+
...current.run,
|
|
163
|
+
...session,
|
|
164
|
+
state: "starting",
|
|
165
|
+
startedAt: this.timestamp(),
|
|
166
|
+
},
|
|
167
|
+
};
|
|
168
|
+
});
|
|
143
169
|
await this.options.store.update(triggerKey, (current) => ({
|
|
144
170
|
...current,
|
|
145
171
|
run: { ...current.run, state: "running" },
|
|
@@ -158,6 +184,11 @@ export class RunOrchestrator {
|
|
|
158
184
|
"Beacon ignores ordinary assistant final text for Delivery.",
|
|
159
185
|
].join("\n"),
|
|
160
186
|
outcome: { ...submission.binding, cliPath: this.options.beaconCliPath },
|
|
187
|
+
session: {
|
|
188
|
+
id: record.run.sessionId,
|
|
189
|
+
path: record.run.sessionPath,
|
|
190
|
+
name: `Beacon ${this.options.profile.id} ${runId}`,
|
|
191
|
+
},
|
|
161
192
|
});
|
|
162
193
|
const outcome = submission.take();
|
|
163
194
|
await this.options.store.update(triggerKey, (current) => ({
|
package/dist/runtime/pi-rpc.js
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { spawn } from "node:child_process";
|
|
2
|
+
import { chmod, mkdir } from "node:fs/promises";
|
|
3
|
+
import { isAbsolute } from "node:path";
|
|
2
4
|
import { createInterface } from "node:readline";
|
|
3
5
|
import { fileURLToPath } from "node:url";
|
|
4
6
|
export class PiRuntimeError extends Error {
|
|
@@ -43,7 +45,15 @@ function collectText(message) {
|
|
|
43
45
|
.trim();
|
|
44
46
|
}
|
|
45
47
|
function buildArguments(request) {
|
|
46
|
-
const args = ["--mode", "rpc", "--no-
|
|
48
|
+
const args = ["--mode", "rpc", "--no-approve"];
|
|
49
|
+
if (request.session) {
|
|
50
|
+
args.push("--session-dir", request.session.path, "--session-id", request.session.id);
|
|
51
|
+
if (request.session.name)
|
|
52
|
+
args.push("--name", request.session.name);
|
|
53
|
+
}
|
|
54
|
+
else {
|
|
55
|
+
args.push("--no-session");
|
|
56
|
+
}
|
|
47
57
|
if (request.provider)
|
|
48
58
|
args.push("--provider", request.provider);
|
|
49
59
|
if (request.model)
|
|
@@ -124,6 +134,24 @@ export async function runPiAgent(request, options = {}) {
|
|
|
124
134
|
if ((request.provider === undefined) !== (request.model === undefined)) {
|
|
125
135
|
throw new Error("Pi Run provider and model must either both be set or both be omitted");
|
|
126
136
|
}
|
|
137
|
+
if (request.session) {
|
|
138
|
+
if (!/^[A-Za-z0-9](?:[A-Za-z0-9._-]*[A-Za-z0-9])?$/.test(request.session.id)) {
|
|
139
|
+
throw new Error("Pi session ID is invalid");
|
|
140
|
+
}
|
|
141
|
+
if (!isAbsolute(request.session.path)) {
|
|
142
|
+
throw new Error("Pi session path must be absolute");
|
|
143
|
+
}
|
|
144
|
+
if (request.session.name !== undefined && !request.session.name.trim()) {
|
|
145
|
+
throw new Error("Pi session name must not be empty");
|
|
146
|
+
}
|
|
147
|
+
try {
|
|
148
|
+
await mkdir(request.session.path, { recursive: true, mode: 0o700 });
|
|
149
|
+
await chmod(request.session.path, 0o700);
|
|
150
|
+
}
|
|
151
|
+
catch (error) {
|
|
152
|
+
throw new PiRuntimeError("runtime_spawn_failed", `Cannot prepare Pi session directory: ${request.session.path}`, { cause: error });
|
|
153
|
+
}
|
|
154
|
+
}
|
|
127
155
|
const executable = options.executable ?? "pi";
|
|
128
156
|
const timeoutMs = options.timeoutMs ?? 5 * 60_000;
|
|
129
157
|
const terminateGraceMs = options.terminateGraceMs ?? 1_000;
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { createHash, randomUUID } from "node:crypto";
|
|
2
2
|
import { chmod, mkdir, open, readdir, readFile, rename, } from "node:fs/promises";
|
|
3
|
-
import { join } from "node:path";
|
|
3
|
+
import { basename, dirname, isAbsolute, join } from "node:path";
|
|
4
4
|
import { z } from "zod";
|
|
5
5
|
import { failureCodes, } from "../domain/types.js";
|
|
6
6
|
const timestamp = z.string().datetime({ offset: true });
|
|
@@ -44,6 +44,15 @@ const inputSchema = z.discriminatedUnion("kind", [
|
|
|
44
44
|
const runSchema = z
|
|
45
45
|
.object({
|
|
46
46
|
runId: z.string().min(1),
|
|
47
|
+
sessionId: z
|
|
48
|
+
.string()
|
|
49
|
+
.regex(/^[A-Za-z0-9](?:[A-Za-z0-9._-]*[A-Za-z0-9])?$/)
|
|
50
|
+
.optional(),
|
|
51
|
+
sessionPath: z
|
|
52
|
+
.string()
|
|
53
|
+
.min(1)
|
|
54
|
+
.refine(isAbsolute, "Pi session path must be absolute")
|
|
55
|
+
.optional(),
|
|
47
56
|
state: z.enum(["queued", "starting", "running", "succeeded", "failed"]),
|
|
48
57
|
queuedAt: timestamp,
|
|
49
58
|
startedAt: timestamp.optional(),
|
|
@@ -96,6 +105,17 @@ function keyFor(profileId, sourceKey) {
|
|
|
96
105
|
}
|
|
97
106
|
function validateRecord(value) {
|
|
98
107
|
const record = triggerRecordSchema.parse(value);
|
|
108
|
+
if (record.run &&
|
|
109
|
+
(record.run.sessionId === undefined) !==
|
|
110
|
+
(record.run.sessionPath === undefined)) {
|
|
111
|
+
throw new Error("Run record must contain both Pi sessionId and sessionPath or neither");
|
|
112
|
+
}
|
|
113
|
+
if (record.run?.sessionId &&
|
|
114
|
+
(record.run.sessionId !== record.run.runId ||
|
|
115
|
+
basename(record.run.sessionPath) !== record.run.runId ||
|
|
116
|
+
basename(dirname(record.run.sessionPath)) !== record.profileId)) {
|
|
117
|
+
throw new Error("Run Pi session identity must map to its Profile ID and Run ID");
|
|
118
|
+
}
|
|
99
119
|
if (record.delivery && !record.finalOutcome) {
|
|
100
120
|
throw new Error("Trigger record with Delivery must contain a Final Outcome");
|
|
101
121
|
}
|
package/examples/config.yaml
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nettee/beacon",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.3",
|
|
4
4
|
"description": "A single-host bridge from messaging and schedules to isolated agent runs",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"repository": {
|
|
@@ -33,6 +33,7 @@
|
|
|
33
33
|
},
|
|
34
34
|
"scripts": {
|
|
35
35
|
"build": "tsc -p tsconfig.json",
|
|
36
|
+
"bump-version": "node scripts/bump-version.mjs",
|
|
36
37
|
"check": "biome check .",
|
|
37
38
|
"dev": "tsx src/cli.ts",
|
|
38
39
|
"e2e:message": "tsc -p tsconfig.e2e.json --noEmit && pnpm build && tsx --test e2e/message/*.e2e.test.ts",
|