@nettee/beacon 0.1.2 → 0.1.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/README.md CHANGED
@@ -31,10 +31,11 @@ Upgrade to the current stable release with the same `latest` command. Run
31
31
  service. Beacon does not manage launchd installation, restart, or rollback in
32
32
  this release.
33
33
 
34
- Beacon only accepts an absolute path for the global configuration:
34
+ Operator commands find the standard Beacon home automatically; a service
35
+ deployment names its configuration explicitly:
35
36
 
36
37
  ```sh
37
- beacon doctor --config /Users/USERNAME/.beacon/config.yaml
38
+ beacon doctor
38
39
  beacon serve --config /Users/USERNAME/.beacon/config.yaml
39
40
  ```
40
41
 
@@ -61,6 +62,20 @@ chmod 700 /Users/USERNAME/.beacon
61
62
  chmod 600 /Users/USERNAME/.beacon/secrets.json
62
63
  ```
63
64
 
65
+ Configure a dedicated absolute Pi session root in `config.yaml`:
66
+
67
+ ```yaml
68
+ pi:
69
+ executable: /ABSOLUTE/PATH/TO/pi
70
+ coding_agent_directory: /Users/USERNAME/.pi/agent
71
+ session_directory: /Users/USERNAME/.beacon/sessions
72
+ ```
73
+
74
+ `pi.session_directory` is optional for compatibility with configurations
75
+ created by Beacon 0.1.2 and earlier; when omitted it defaults to `sessions/`
76
+ beside `config.yaml`. When configured, it must be absolute. Beacon creates
77
+ per-Run directories with mode `0700` when Pi starts.
78
+
64
79
  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
80
 
66
81
  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.
@@ -69,22 +84,65 @@ Each Schedule uses a five-field cron expression and an IANA timezone. Its `deliv
69
84
 
70
85
  ```text
71
86
  beacon serve --config <absolute-path>
72
- beacon doctor --config <absolute-path>
73
- beacon trigger --config <absolute-path> --profile <id> --input -
87
+ beacon doctor [--config <absolute-path>]
88
+ beacon trigger [--config <absolute-path>] --profile <id> --input -
89
+ beacon schedule trigger [--config <absolute-path>] --profile <id> --schedule <id>
74
90
  beacon version
75
91
  ```
76
92
 
93
+ Operator commands use `~/.beacon/config.yaml` when `--config` is omitted.
94
+ An explicit override must still be an absolute path. `serve` keeps requiring an
95
+ explicit absolute config path so service definitions identify their deployment
96
+ configuration unambiguously.
97
+
77
98
  An operator can run one Profile without Feishu delivery by piping input to `trigger`; the Final Outcome is printed to stdout:
78
99
 
79
100
  ```sh
80
101
  printf '%s\n' 'Summarize the workspace status.' | \
81
- beacon trigger --config /Users/USERNAME/.beacon/config.yaml --profile example --input -
102
+ beacon trigger --profile example --input -
103
+ ```
104
+
105
+ An operator can also immediately run a configured Schedule through the real
106
+ Schedule input and Feishu chat Delivery path:
107
+
108
+ ```sh
109
+ beacon schedule trigger --profile example --schedule daily-report
82
110
  ```
83
111
 
112
+ This creates a distinct durable Run on every invocation and prints its
113
+ `run_id`. It uses the Schedule's configured `input` and `delivery.chat_id`, but
114
+ does not read, initialize, or advance the Schedule's cron cursor. Unknown
115
+ Profiles or Schedules and failed Runs or Deliveries exit non-zero; failure
116
+ messages include the `run_id` whenever a Run record was created.
117
+
84
118
  ## State and failure behavior
85
119
 
86
120
  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
121
 
122
+ Every new business Run also owns a permanent Pi session. Its Run record stores
123
+ both `sessionId` and `sessionPath`; the session ID is exactly the Beacon
124
+ `runId`, and the path is the dedicated directory
125
+ `<pi.session_directory>/<profile-id>/<runId>/`. Pi receives that mapping via
126
+ `--session-dir`, `--session-id`, and a readable `Beacon <profile-id> <runId>`
127
+ name. The directory contains the Pi JSONL session file and is never cleaned up
128
+ by Beacon. A queued Run keeps the same mapping after restart. Queued records
129
+ written by Beacon 0.1.2 or earlier are assigned the same deterministic mapping
130
+ when recovered. Historical completed records remain readable and may omit the
131
+ two session fields because those Runs were originally ephemeral.
132
+
133
+ To locate a session from a reported `run_id`, first find its durable Run
134
+ record, then inspect or export the sole JSONL file in `sessionPath`:
135
+
136
+ ```sh
137
+ rg -l '"runId": "run_REPORTED_ID"' /Users/USERNAME/.beacon/profiles/*/state/triggers/*/record.json
138
+ jq '.run | {runId, sessionId, sessionPath, state}' /ABSOLUTE/PATH/TO/record.json
139
+ find /ABSOLUTE/SESSION/PATH -maxdepth 1 -name '*.jsonl' -print
140
+ pi --export /ABSOLUTE/SESSION/PATH/TIMESTAMP_run_REPORTED_ID.jsonl run.html
141
+ ```
142
+
143
+ `beacon doctor` keeps using `--no-session`, so its Pi smoke tests do not create
144
+ diagnostic session files.
145
+
88
146
  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
147
 
90
148
  Secrets and ephemeral Run Capability tokens are excluded from persisted records and from the Pi environment except for the one Run-scoped Outcome capability.
@@ -105,6 +163,12 @@ launchctl print gui/$(id -u)/io.nettee.beacon
105
163
 
106
164
  The example uses a restrictive umask and asks launchd to restart only after abnormal exit. `SIGTERM` initiates an orderly shutdown: timers and Gateways stop, accepted intake work drains, and the local Outcome server closes.
107
165
 
166
+ The repository also contains Chinese operator runbooks based on the verified
167
+ `macmini.liuyi` deployment:
168
+
169
+ - [Deploy or update Beacon on macmini](docs/macmini-deploy-and-update.md)
170
+ - [Add a Beacon Profile on macmini](docs/macmini-add-profile.md)
171
+
108
172
  ## Development checks
109
173
 
110
174
  Development requires pnpm 10.33.2. Install dependencies before running checks
@@ -131,7 +195,7 @@ installed `beacon` binary without using the source tree.
131
195
  `pnpm e2e:message` sends one synthetic Feishu direct message through an
132
196
  in-memory Gateway, the production message pipeline, and a real Pi model. The
133
197
  same in-memory Gateway captures the quoted reply without contacting Feishu. It defaults to
134
- `openai-codex/gpt-5.3-codex-spark`; override the runtime with
198
+ `openai-codex/gpt-5.6-luna:low`; override the runtime with
135
199
  `BEACON_E2E_MESSAGE_PROVIDER`, `BEACON_E2E_MESSAGE_MODEL`,
136
200
  `BEACON_E2E_MESSAGE_PI_EXECUTABLE`, or
137
201
  `BEACON_E2E_MESSAGE_PI_CODING_AGENT_DIRECTORY`.
package/dist/cli-app.js CHANGED
@@ -1,18 +1,40 @@
1
- import { isAbsolute } from "node:path";
1
+ import { homedir } from "node:os";
2
+ import { isAbsolute, join } from "node:path";
3
+ export const defaultConfigPath = join(homedir(), ".beacon", "config.yaml");
2
4
  function usage() {
3
5
  throw new Error(`Usage:
4
6
  beacon serve --config <absolute-path>
5
- beacon doctor --config <absolute-path>
6
- beacon trigger --config <absolute-path> --profile <id> --input -
7
+ beacon doctor [--config <absolute-path>]
8
+ beacon trigger [--config <absolute-path>] --profile <id> --input -
9
+ beacon schedule trigger [--config <absolute-path>] --profile <id> --schedule <id>
7
10
  beacon version`);
8
11
  }
9
- function configOnly(args) {
12
+ function requiredConfig(args) {
10
13
  if (args.length !== 2 || args[0] !== "--config" || !args[1])
11
14
  usage();
12
15
  if (!isAbsolute(args[1]))
13
16
  throw new Error("Config path must be absolute");
14
17
  return args[1];
15
18
  }
19
+ function options(args) {
20
+ if (args.length % 2 !== 0)
21
+ usage();
22
+ const values = new Map();
23
+ for (let index = 0; index < args.length; index += 2) {
24
+ const key = args[index];
25
+ const value = args[index + 1];
26
+ if (!key?.startsWith("--") || !value || values.has(key))
27
+ usage();
28
+ values.set(key, value);
29
+ }
30
+ return values;
31
+ }
32
+ function optionalConfig(values) {
33
+ const config = values.get("--config") ?? defaultConfigPath;
34
+ if (!isAbsolute(config))
35
+ throw new Error("Config path must be absolute");
36
+ return config;
37
+ }
16
38
  export function parseCli(args) {
17
39
  const [command, ...rest] = args;
18
40
  if (command === "version") {
@@ -20,30 +42,38 @@ export function parseCli(args) {
20
42
  usage();
21
43
  return { command: "version" };
22
44
  }
23
- if (command === "serve" || command === "doctor") {
24
- return { command, config: configOnly(rest) };
45
+ if (command === "serve") {
46
+ return { command, config: requiredConfig(rest) };
25
47
  }
26
- if (command === "trigger") {
27
- if (rest.length !== 6)
48
+ if (command === "doctor") {
49
+ const values = options(rest);
50
+ if ([...values.keys()].some((key) => key !== "--config"))
28
51
  usage();
29
- const values = new Map();
30
- for (let index = 0; index < rest.length; index += 2) {
31
- const key = rest[index];
32
- const value = rest[index + 1];
33
- if (!key?.startsWith("--") || !value || values.has(key))
34
- usage();
35
- values.set(key, value);
36
- }
37
- const config = values.get("--config");
52
+ return { command, config: optionalConfig(values) };
53
+ }
54
+ if (command === "trigger") {
55
+ const values = options(rest);
38
56
  const profile = values.get("--profile");
39
- if (!config ||
40
- !profile ||
57
+ if (!profile ||
41
58
  values.get("--input") !== "-" ||
42
- values.size !== 3)
59
+ [...values.keys()].some((key) => !["--config", "--profile", "--input"].includes(key)))
60
+ usage();
61
+ return { command: "trigger", config: optionalConfig(values), profile };
62
+ }
63
+ if (command === "schedule" && rest[0] === "trigger") {
64
+ const values = options(rest.slice(1));
65
+ const profile = values.get("--profile");
66
+ const schedule = values.get("--schedule");
67
+ if (!profile ||
68
+ !schedule ||
69
+ [...values.keys()].some((key) => !["--config", "--profile", "--schedule"].includes(key)))
43
70
  usage();
44
- if (!isAbsolute(config))
45
- throw new Error("Config path must be absolute");
46
- return { command: "trigger", config, profile };
71
+ return {
72
+ command: "schedule-trigger",
73
+ config: optionalConfig(values),
74
+ profile,
75
+ schedule,
76
+ };
47
77
  }
48
78
  if (command === "outcome" && rest.length === 1 && rest[0] === "submit") {
49
79
  return { command: "outcome-submit" };
@@ -65,6 +95,9 @@ export async function runCli(command, dependencies) {
65
95
  if (command.command === "trigger") {
66
96
  return dependencies.trigger(command.config, command.profile, await readStdin());
67
97
  }
98
+ if (command.command === "schedule-trigger") {
99
+ return dependencies.triggerSchedule(command.config, command.profile, command.schedule);
100
+ }
68
101
  if (command.command === "outcome-submit")
69
102
  return dependencies.submitOutcome();
70
103
  console.log(dependencies.version);
package/dist/cli.js CHANGED
@@ -3,6 +3,7 @@ import { parseCli, runCli } from "./cli-app.js";
3
3
  import { runDoctor } from "./doctor.js";
4
4
  import { runManualTrigger } from "./manual-trigger.js";
5
5
  import { submitOutcomeFromCli } from "./outcome/submit.js";
6
+ import { runScheduleTrigger } from "./schedule-trigger.js";
6
7
  import { runBeacon } from "./service.js";
7
8
  import { packageVersion } from "./version.js";
8
9
  async function main() {
@@ -10,6 +11,7 @@ async function main() {
10
11
  serve: runBeacon,
11
12
  doctor: runDoctor,
12
13
  trigger: runManualTrigger,
14
+ triggerSchedule: runScheduleTrigger,
13
15
  submitOutcome: submitOutcomeFromCli,
14
16
  version: packageVersion,
15
17
  });
@@ -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: { executable, codingAgentDirectory },
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,
@@ -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
- ...current,
141
- run: { ...current.run, state: "starting", startedAt: this.timestamp() },
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) => ({
@@ -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-session", "--no-approve"];
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;
@@ -0,0 +1,81 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { loadGlobalConfig } from "./config/global.js";
3
+ import { loadProfileRegistry } from "./config/registry.js";
4
+ import { loadFeishuCredentials } from "./config/secrets.js";
5
+ import { createFeishuGateway } from "./feishu/gateway.js";
6
+ import { startOutcomeServer } from "./outcome/server.js";
7
+ import { createPiRunOrchestrator } from "./run/create-pi-orchestrator.js";
8
+ import { RunQueue } from "./run/queue.js";
9
+ import { TriggerStore } from "./state/trigger-store.js";
10
+ function findSchedule(profile, scheduleId) {
11
+ const schedule = profile.schedules.find((candidate) => candidate.id === scheduleId);
12
+ if (!schedule) {
13
+ throw new Error(`Unknown Schedule in Profile ${profile.id}: ${scheduleId}`);
14
+ }
15
+ return schedule;
16
+ }
17
+ export async function triggerScheduleOnce(options) {
18
+ const schedule = findSchedule(options.profile, options.scheduleId);
19
+ const scheduledFor = (options.now ?? (() => new Date()))().toISOString();
20
+ const invocationId = (options.id ?? randomUUID)();
21
+ const claim = await options.store.claim({
22
+ sourceKey: ["manual-schedule", schedule.id, invocationId],
23
+ target: { kind: "chat", chatId: schedule.delivery.chatId },
24
+ ingress: {
25
+ invocation: "manual",
26
+ scheduleId: schedule.id,
27
+ scheduledFor,
28
+ text: schedule.input,
29
+ },
30
+ });
31
+ if (!claim.created) {
32
+ throw new Error(`Duplicate manual Schedule invocation: ${invocationId}`);
33
+ }
34
+ await options.process(claim.record.triggerKey, async () => ({
35
+ kind: "schedule",
36
+ scheduleId: schedule.id,
37
+ scheduledFor,
38
+ text: schedule.input,
39
+ }));
40
+ const record = (await options.store.list()).find((candidate) => candidate.triggerKey === claim.record.triggerKey);
41
+ if (!record?.run) {
42
+ throw new Error(`Schedule Trigger produced no Run record: trigger_id=${claim.record.triggerId}`);
43
+ }
44
+ if (record.run.state !== "succeeded" ||
45
+ record.delivery?.state !== "delivered") {
46
+ const failure = record.run.failure?.code ?? record.delivery?.failure?.code ?? "unknown";
47
+ throw new Error(`Schedule Trigger failed (run_id=${record.run.runId}): ${failure}`);
48
+ }
49
+ return record;
50
+ }
51
+ export async function runScheduleTrigger(configPath, profileId, scheduleId) {
52
+ const global = await loadGlobalConfig(configPath);
53
+ const profiles = await loadProfileRegistry(global.profilesDirectory);
54
+ const profile = profiles.find((candidate) => candidate.id === profileId);
55
+ if (!profile)
56
+ throw new Error(`Unknown Profile: ${profileId}`);
57
+ findSchedule(profile, scheduleId);
58
+ const credentials = await loadFeishuCredentials(profile.id, global.secretsPath);
59
+ const outcomes = await startOutcomeServer();
60
+ try {
61
+ const store = new TriggerStore(profile.directory, profile.id);
62
+ const orchestrator = createPiRunOrchestrator({
63
+ config: global,
64
+ profile,
65
+ store,
66
+ queue: new RunQueue(global.runs.maxConcurrent, global.runs.maxQueued),
67
+ outcomes,
68
+ delivery: createFeishuGateway(credentials),
69
+ });
70
+ const record = await triggerScheduleOnce({
71
+ profile,
72
+ scheduleId,
73
+ store,
74
+ process: (triggerKey, normalize) => orchestrator.process(triggerKey, normalize),
75
+ });
76
+ console.log(`[beacon] Schedule Trigger succeeded run_id=${record.run.runId}`);
77
+ }
78
+ finally {
79
+ await outcomes.close();
80
+ }
81
+ }
@@ -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
  }
@@ -3,6 +3,7 @@ profiles_directory: profiles
3
3
  pi:
4
4
  executable: /ABSOLUTE/PATH/TO/pi
5
5
  coding_agent_directory: /Users/USERNAME/.pi/agent
6
+ session_directory: /Users/USERNAME/.beacon/sessions
6
7
  runs:
7
8
  max_concurrent: 2
8
9
  max_queued: 100
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nettee/beacon",
3
- "version": "0.1.2",
3
+ "version": "0.1.4",
4
4
  "description": "A single-host bridge from messaging and schedules to isolated agent runs",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {