@agent-plan/server 0.4.1 → 0.4.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.
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Read-only API v1 for programs outside Agent Plan (docs/api-v1.md).
3
+ *
4
+ * This is deliberately separate from the web UI's own routes in serve.ts: those
5
+ * change whenever the UI does, these are promised not to. Every reply is built
6
+ * from the plan-core contract mappers (never from raw stored objects) and is
7
+ * wrapped in `{ apiVersion, data }` or `{ apiVersion, error }`.
8
+ */
9
+ import { Hono } from "hono";
10
+ import { type PlanStore } from "@agent-plan/core";
11
+ /** Mount point, fixed: the version is part of the URL so a future v2 can live beside it. */
12
+ export declare const API_V1_BASE_PATH = "/api/v1";
13
+ /** Routes are relative: mount the result at API_V1_BASE_PATH. */
14
+ export declare function createApiV1App(store: PlanStore, isBusy?: () => boolean): Hono;
15
+ //# sourceMappingURL=api-v1.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"api-v1.d.ts","sourceRoot":"","sources":["../src/api-v1.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAEH,OAAO,EAAE,IAAI,EAAgB,MAAM,MAAM,CAAC;AAC1C,OAAO,EAQL,KAAK,SAAS,EACf,MAAM,kBAAkB,CAAC;AAE1B,4FAA4F;AAC5F,eAAO,MAAM,gBAAgB,YAAY,CAAC;AAgE1C,iEAAiE;AACjE,wBAAgB,cAAc,CAAC,KAAK,EAAE,SAAS,EAAE,MAAM,CAAC,EAAE,MAAM,OAAO,GAAG,IAAI,CAyH7E"}
package/dist/api-v1.js ADDED
@@ -0,0 +1,181 @@
1
+ /**
2
+ * Read-only API v1 for programs outside Agent Plan (docs/api-v1.md).
3
+ *
4
+ * This is deliberately separate from the web UI's own routes in serve.ts: those
5
+ * change whenever the UI does, these are promised not to. Every reply is built
6
+ * from the plan-core contract mappers (never from raw stored objects) and is
7
+ * wrapped in `{ apiVersion, data }` or `{ apiVersion, error }`.
8
+ */
9
+ import { Hono } from "hono";
10
+ import { API_V1_VERSION, PlanStoreError, PlanWriterBusyError, apiV1Envelope, apiV1Error, buildApiV1Model, } from "@agent-plan/core";
11
+ /** Mount point, fixed: the version is part of the URL so a future v2 can live beside it. */
12
+ export const API_V1_BASE_PATH = "/api/v1";
13
+ /** Fields dropped by `?compact=true` so long lists stay small. */
14
+ const COMPACT_FIELDS = ["description", "content", "guidelines", "decision", "rationale", "implementationNotes"];
15
+ async function loadModel(store) {
16
+ const [project, features, phases, ideas] = await Promise.all([
17
+ store.loadProject(),
18
+ store.loadFeatures(),
19
+ store.loadAllPhases(),
20
+ store.loadIdeas(),
21
+ ]);
22
+ return buildApiV1Model({ project, features: features.features, phases, ideas: ideas.ideas });
23
+ }
24
+ /** `P002(F001)` -> `P002`; `P002(F001)/T005` -> `P002/T005`. */
25
+ function withoutFeature(ref) {
26
+ return ref.replace(/\(F\d+\)/i, "");
27
+ }
28
+ /** Exact lookup by id, composite ref, short id, or the ref without its feature. No fuzzy title matching: a stable API never guesses. */
29
+ function findByRef(items, rawRef) {
30
+ const ref = rawRef.trim().toLowerCase();
31
+ if (!ref)
32
+ return undefined;
33
+ const isTaskSuffix = /^t\d+$/.test(ref);
34
+ return items.find((item) => {
35
+ const itemRef = item.ref.toLowerCase();
36
+ return item.id.toLowerCase() === ref
37
+ || itemRef === ref
38
+ || withoutFeature(itemRef) === ref
39
+ || (item.shortId ? item.shortId.toLowerCase() === ref : false)
40
+ // A task number is unique across the project, so `T005` alone is unambiguous.
41
+ || (isTaskSuffix && itemRef.endsWith(`/${ref}`));
42
+ });
43
+ }
44
+ function compact(view) {
45
+ const copy = { ...view };
46
+ for (const field of COMPACT_FIELDS)
47
+ if (field in copy)
48
+ delete copy[field];
49
+ return copy;
50
+ }
51
+ function maybeCompact(c, items) {
52
+ return c.req.query("compact") === "true" ? items.map(compact) : items;
53
+ }
54
+ /** The path after the mount point, decoded, so refs like `P002(F001)/T005` (which contain `/`) work. */
55
+ function refFromPath(c, collection) {
56
+ const path = c.req.path;
57
+ const start = path.indexOf(`${API_V1_BASE_PATH}/${collection}/`);
58
+ const raw = start === -1 ? "" : path.slice(start + API_V1_BASE_PATH.length + collection.length + 2);
59
+ try {
60
+ return decodeURIComponent(raw);
61
+ }
62
+ catch {
63
+ return raw;
64
+ }
65
+ }
66
+ /** Routes are relative: mount the result at API_V1_BASE_PATH. */
67
+ export function createApiV1App(store, isBusy) {
68
+ const app = new Hono();
69
+ const notFound = (c, what, ref) => c.json(apiV1Error("NOT_FOUND", `${what} not found: ${ref}`), 404);
70
+ app.onError((err, c) => {
71
+ if (err instanceof PlanWriterBusyError)
72
+ return c.json(apiV1Error("PLAN_BUSY", err.message), 503);
73
+ if (err instanceof PlanStoreError || /ENOENT|read failed/i.test(err.message)) {
74
+ return c.json(apiV1Error("NOT_FOUND", err.message), 404);
75
+ }
76
+ return c.json(apiV1Error("INTERNAL", err instanceof Error ? err.message : String(err)), 500);
77
+ });
78
+ app.use("*", async (c, next) => {
79
+ if (c.req.method !== "GET") {
80
+ c.header("Allow", "GET");
81
+ return c.json(apiV1Error("METHOD_NOT_ALLOWED", "API v1 is read-only. Use GET."), 405);
82
+ }
83
+ // While an agent is writing .planner/ files, a read could see a half-written state.
84
+ if (isBusy?.())
85
+ return c.json(apiV1Error("PLAN_BUSY", "Plan files are being updated. Retry shortly."), 503);
86
+ await next();
87
+ });
88
+ app.get("/", (c) => c.json(apiV1Envelope({
89
+ apiVersion: API_V1_VERSION,
90
+ links: {
91
+ project: `${API_V1_BASE_PATH}/project`,
92
+ features: `${API_V1_BASE_PATH}/features`,
93
+ phases: `${API_V1_BASE_PATH}/phases`,
94
+ tasks: `${API_V1_BASE_PATH}/tasks`,
95
+ decisions: `${API_V1_BASE_PATH}/decisions`,
96
+ handoffs: `${API_V1_BASE_PATH}/handoffs`,
97
+ ideas: `${API_V1_BASE_PATH}/ideas`,
98
+ },
99
+ })));
100
+ app.get(`/project`, async (c) => c.json(apiV1Envelope((await loadModel(store)).project)));
101
+ app.get(`/features`, async (c) => {
102
+ const { features } = await loadModel(store);
103
+ const status = c.req.query("status");
104
+ return c.json(apiV1Envelope(maybeCompact(c, status ? features.filter((feature) => feature.status === status) : features)));
105
+ });
106
+ app.get(`/features/*`, async (c) => {
107
+ const ref = refFromPath(c, "features");
108
+ const feature = findByRef((await loadModel(store)).features, ref);
109
+ return feature ? c.json(apiV1Envelope(feature)) : notFound(c, "Feature", ref);
110
+ });
111
+ app.get(`/phases`, async (c) => {
112
+ const model = await loadModel(store);
113
+ let phases = model.phases;
114
+ const featureRef = c.req.query("feature");
115
+ if (featureRef) {
116
+ const feature = findByRef(model.features, featureRef);
117
+ if (!feature)
118
+ return notFound(c, "Feature", featureRef);
119
+ phases = phases.filter((phase) => phase.featureRef === feature.ref);
120
+ }
121
+ const status = c.req.query("status");
122
+ if (status)
123
+ phases = phases.filter((phase) => phase.status === status);
124
+ return c.json(apiV1Envelope(maybeCompact(c, phases)));
125
+ });
126
+ app.get(`/phases/*`, async (c) => {
127
+ const ref = refFromPath(c, "phases");
128
+ const phase = findByRef((await loadModel(store)).phases, ref);
129
+ return phase ? c.json(apiV1Envelope(phase)) : notFound(c, "Phase", ref);
130
+ });
131
+ app.get(`/tasks`, async (c) => {
132
+ const model = await loadModel(store);
133
+ let tasks = model.tasks;
134
+ const phaseRef = c.req.query("phase");
135
+ if (phaseRef) {
136
+ const phase = findByRef(model.phases, phaseRef);
137
+ if (!phase)
138
+ return notFound(c, "Phase", phaseRef);
139
+ tasks = tasks.filter((task) => task.phaseRef === phase.ref);
140
+ }
141
+ const status = c.req.query("status");
142
+ if (status)
143
+ tasks = tasks.filter((task) => task.status === status);
144
+ return c.json(apiV1Envelope(maybeCompact(c, tasks)));
145
+ });
146
+ app.get(`/tasks/*`, async (c) => {
147
+ const ref = refFromPath(c, "tasks");
148
+ const task = findByRef((await loadModel(store)).tasks, ref);
149
+ return task ? c.json(apiV1Envelope(task)) : notFound(c, "Task", ref);
150
+ });
151
+ app.get(`/decisions`, async (c) => {
152
+ const { decisions } = await loadModel(store);
153
+ const owner = c.req.query("owner");
154
+ const filtered = !owner
155
+ ? decisions
156
+ : decisions.filter((decision) => owner.toLowerCase() === "project"
157
+ ? decision.owner.kind === "project"
158
+ : decision.owner.ref !== null && (decision.owner.ref.toLowerCase() === owner.toLowerCase()
159
+ || withoutFeature(decision.owner.ref).toLowerCase() === owner.toLowerCase()));
160
+ return c.json(apiV1Envelope(maybeCompact(c, filtered)));
161
+ });
162
+ app.get(`/handoffs`, async (c) => {
163
+ const { handoffs } = await loadModel(store);
164
+ return c.json(apiV1Envelope(maybeCompact(c, handoffs)));
165
+ });
166
+ app.get(`/handoffs/*`, async (c) => {
167
+ const ref = refFromPath(c, "handoffs");
168
+ const { handoffs, phases } = await loadModel(store);
169
+ const phase = findByRef(phases, ref);
170
+ const handoff = phase ? handoffs.find((entry) => entry.phaseRef === phase.ref) : undefined;
171
+ return handoff ? c.json(apiV1Envelope(handoff)) : notFound(c, "Handoff", ref);
172
+ });
173
+ app.get(`/ideas`, async (c) => c.json(apiV1Envelope(maybeCompact(c, (await loadModel(store)).ideas))));
174
+ app.get(`/ideas/*`, async (c) => {
175
+ const ref = refFromPath(c, "ideas");
176
+ const idea = findByRef((await loadModel(store)).ideas, ref);
177
+ return idea ? c.json(apiV1Envelope(idea)) : notFound(c, "Idea", ref);
178
+ });
179
+ app.all("*", (c) => c.json(apiV1Error("NOT_FOUND", `No such API v1 route: ${c.req.path}`), 404));
180
+ return app;
181
+ }
@@ -0,0 +1,65 @@
1
+ /**
2
+ * Change detector for the `entity-changed` live event (docs/api-v1.md).
3
+ *
4
+ * Agents write to `.planner/` from other processes (the MCP server), so route
5
+ * handlers in this process never see those writes. Only the folder watcher and
6
+ * `/internal/notify` do, and they only say "something changed". This detector
7
+ * turns that signal into exact answers: it keeps a fingerprint of every API v1
8
+ * view, reloads the plan on each signal, and reports which entities were
9
+ * created, updated or deleted.
10
+ */
11
+ import { API_V1_VERSION, type ApiV1EntityEntry, type ApiV1EntityKind, type PlanStore } from "@agent-plan/core";
12
+ export type EntityChangeOp = "created" | "updated" | "deleted";
13
+ export interface EntityChangedData {
14
+ apiVersion: typeof API_V1_VERSION;
15
+ kind: ApiV1EntityKind;
16
+ ref: string;
17
+ id: string;
18
+ op: EntityChangeOp;
19
+ parents: {
20
+ featureRef?: string;
21
+ phaseRef?: string;
22
+ };
23
+ }
24
+ interface Fingerprint {
25
+ kind: ApiV1EntityKind;
26
+ id: string;
27
+ ref: string;
28
+ parents: ApiV1EntityEntry["parents"];
29
+ hash: string;
30
+ }
31
+ export type EntitySnapshot = ReadonlyMap<string, Fingerprint>;
32
+ export declare function snapshotOf(entries: readonly ApiV1EntityEntry[]): EntitySnapshot;
33
+ /** Exactly what differs between two snapshots, one change per entity. */
34
+ export declare function diffSnapshots(previous: EntitySnapshot, next: EntitySnapshot): EntityChangedData[];
35
+ export interface EntityEventDetectorOptions {
36
+ /** Wait this long after the last signal before reloading, so one burst of writes is one pass. */
37
+ debounceMs?: number;
38
+ /** Wait before retrying when the plan could not be loaded (for example mid-write). */
39
+ retryMs?: number;
40
+ maxRetries?: number;
41
+ }
42
+ export declare class EntityEventDetector {
43
+ private readonly store;
44
+ private readonly emit;
45
+ private snapshot;
46
+ private timer;
47
+ private running;
48
+ private dirty;
49
+ private retries;
50
+ private closed;
51
+ private readonly debounceMs;
52
+ private readonly retryMs;
53
+ private readonly maxRetries;
54
+ constructor(store: PlanStore, emit: (change: EntityChangedData) => void, options?: EntityEventDetectorOptions);
55
+ private currentSnapshot;
56
+ /** Record the starting state. If it cannot be read yet, the first successful pass only records and never reports. */
57
+ prime(): Promise<void>;
58
+ /** Something in `.planner/` may have changed. */
59
+ signal(): void;
60
+ private schedule;
61
+ private run;
62
+ close(): void;
63
+ }
64
+ export {};
65
+ //# sourceMappingURL=entity-events.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"entity-events.d.ts","sourceRoot":"","sources":["../src/entity-events.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AAGH,OAAO,EACL,cAAc,EAGd,KAAK,gBAAgB,EACrB,KAAK,eAAe,EACpB,KAAK,SAAS,EACf,MAAM,kBAAkB,CAAC;AAE1B,MAAM,MAAM,cAAc,GAAG,SAAS,GAAG,SAAS,GAAG,SAAS,CAAC;AAE/D,MAAM,WAAW,iBAAiB;IAChC,UAAU,EAAE,OAAO,cAAc,CAAC;IAClC,IAAI,EAAE,eAAe,CAAC;IACtB,GAAG,EAAE,MAAM,CAAC;IACZ,EAAE,EAAE,MAAM,CAAC;IACX,EAAE,EAAE,cAAc,CAAC;IACnB,OAAO,EAAE;QAAE,UAAU,CAAC,EAAE,MAAM,CAAC;QAAC,QAAQ,CAAC,EAAE,MAAM,CAAA;KAAE,CAAC;CACrD;AAED,UAAU,WAAW;IACnB,IAAI,EAAE,eAAe,CAAC;IACtB,EAAE,EAAE,MAAM,CAAC;IACX,GAAG,EAAE,MAAM,CAAC;IACZ,OAAO,EAAE,gBAAgB,CAAC,SAAS,CAAC,CAAC;IACrC,IAAI,EAAE,MAAM,CAAC;CACd;AAED,MAAM,MAAM,cAAc,GAAG,WAAW,CAAC,MAAM,EAAE,WAAW,CAAC,CAAC;AAE9D,wBAAgB,UAAU,CAAC,OAAO,EAAE,SAAS,gBAAgB,EAAE,GAAG,cAAc,CAY/E;AAMD,yEAAyE;AACzE,wBAAgB,aAAa,CAAC,QAAQ,EAAE,cAAc,EAAE,IAAI,EAAE,cAAc,GAAG,iBAAiB,EAAE,CAWjG;AAED,MAAM,WAAW,0BAA0B;IACzC,iGAAiG;IACjG,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,sFAAsF;IACtF,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,UAAU,CAAC,EAAE,MAAM,CAAC;CACrB;AAED,qBAAa,mBAAmB;IAY5B,OAAO,CAAC,QAAQ,CAAC,KAAK;IACtB,OAAO,CAAC,QAAQ,CAAC,IAAI;IAZvB,OAAO,CAAC,QAAQ,CAA+B;IAC/C,OAAO,CAAC,KAAK,CAA8C;IAC3D,OAAO,CAAC,OAAO,CAAS;IACxB,OAAO,CAAC,KAAK,CAAS;IACtB,OAAO,CAAC,OAAO,CAAK;IACpB,OAAO,CAAC,MAAM,CAAS;IACvB,OAAO,CAAC,QAAQ,CAAC,UAAU,CAAS;IACpC,OAAO,CAAC,QAAQ,CAAC,OAAO,CAAS;IACjC,OAAO,CAAC,QAAQ,CAAC,UAAU,CAAS;gBAGjB,KAAK,EAAE,SAAS,EAChB,IAAI,EAAE,CAAC,MAAM,EAAE,iBAAiB,KAAK,IAAI,EAC1D,OAAO,GAAE,0BAA+B;YAO5B,eAAe;IAU7B,qHAAqH;IAC/G,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC;IAQ5B,iDAAiD;IACjD,MAAM,IAAI,IAAI;IAMd,OAAO,CAAC,QAAQ;YAQF,GAAG;IA2BjB,KAAK,IAAI,IAAI;CAKd"}
@@ -0,0 +1,133 @@
1
+ /**
2
+ * Change detector for the `entity-changed` live event (docs/api-v1.md).
3
+ *
4
+ * Agents write to `.planner/` from other processes (the MCP server), so route
5
+ * handlers in this process never see those writes. Only the folder watcher and
6
+ * `/internal/notify` do, and they only say "something changed". This detector
7
+ * turns that signal into exact answers: it keeps a fingerprint of every API v1
8
+ * view, reloads the plan on each signal, and reports which entities were
9
+ * created, updated or deleted.
10
+ */
11
+ import { createHash } from "node:crypto";
12
+ import { API_V1_VERSION, buildApiV1Model, listApiV1Entities, } from "@agent-plan/core";
13
+ export function snapshotOf(entries) {
14
+ const snapshot = new Map();
15
+ for (const entry of entries) {
16
+ snapshot.set(`${entry.kind}:${entry.key}`, {
17
+ kind: entry.kind,
18
+ id: entry.id,
19
+ ref: entry.ref,
20
+ parents: entry.parents,
21
+ hash: createHash("sha1").update(JSON.stringify(entry.view)).digest("hex"),
22
+ });
23
+ }
24
+ return snapshot;
25
+ }
26
+ function changeOf(fingerprint, op) {
27
+ return { apiVersion: API_V1_VERSION, kind: fingerprint.kind, ref: fingerprint.ref, id: fingerprint.id, op, parents: fingerprint.parents };
28
+ }
29
+ /** Exactly what differs between two snapshots, one change per entity. */
30
+ export function diffSnapshots(previous, next) {
31
+ const changes = [];
32
+ for (const [key, fingerprint] of next) {
33
+ const before = previous.get(key);
34
+ if (!before)
35
+ changes.push(changeOf(fingerprint, "created"));
36
+ else if (before.hash !== fingerprint.hash)
37
+ changes.push(changeOf(fingerprint, "updated"));
38
+ }
39
+ for (const [key, fingerprint] of previous) {
40
+ if (!next.has(key))
41
+ changes.push(changeOf(fingerprint, "deleted"));
42
+ }
43
+ return changes;
44
+ }
45
+ export class EntityEventDetector {
46
+ store;
47
+ emit;
48
+ snapshot = null;
49
+ timer = null;
50
+ running = false;
51
+ dirty = false;
52
+ retries = 0;
53
+ closed = false;
54
+ debounceMs;
55
+ retryMs;
56
+ maxRetries;
57
+ constructor(store, emit, options = {}) {
58
+ this.store = store;
59
+ this.emit = emit;
60
+ this.debounceMs = options.debounceMs ?? 100;
61
+ this.retryMs = options.retryMs ?? 300;
62
+ this.maxRetries = options.maxRetries ?? 10;
63
+ }
64
+ async currentSnapshot() {
65
+ const [project, features, phases, ideas] = await Promise.all([
66
+ this.store.loadProject(),
67
+ this.store.loadFeatures(),
68
+ this.store.loadAllPhases(),
69
+ this.store.loadIdeas(),
70
+ ]);
71
+ return snapshotOf(listApiV1Entities(buildApiV1Model({ project, features: features.features, phases, ideas: ideas.ideas })));
72
+ }
73
+ /** Record the starting state. If it cannot be read yet, the first successful pass only records and never reports. */
74
+ async prime() {
75
+ try {
76
+ this.snapshot = await this.currentSnapshot();
77
+ }
78
+ catch {
79
+ this.snapshot = null;
80
+ }
81
+ }
82
+ /** Something in `.planner/` may have changed. */
83
+ signal() {
84
+ if (this.closed)
85
+ return;
86
+ this.retries = 0;
87
+ this.schedule(this.debounceMs);
88
+ }
89
+ schedule(delayMs) {
90
+ if (this.timer)
91
+ clearTimeout(this.timer);
92
+ this.timer = setTimeout(() => {
93
+ this.timer = null;
94
+ void this.run();
95
+ }, delayMs);
96
+ }
97
+ async run() {
98
+ if (this.running) {
99
+ this.dirty = true;
100
+ return;
101
+ }
102
+ this.running = true;
103
+ try {
104
+ const next = await this.currentSnapshot();
105
+ const previous = this.snapshot;
106
+ this.snapshot = next;
107
+ if (previous)
108
+ for (const change of diffSnapshots(previous, next))
109
+ this.emit(change);
110
+ this.retries = 0;
111
+ }
112
+ catch {
113
+ // A writer is mid-update. Try again shortly instead of waiting for another signal.
114
+ if (!this.closed && this.retries < this.maxRetries) {
115
+ this.retries += 1;
116
+ this.schedule(this.retryMs);
117
+ }
118
+ }
119
+ finally {
120
+ this.running = false;
121
+ if (this.dirty && !this.closed) {
122
+ this.dirty = false;
123
+ this.schedule(this.debounceMs);
124
+ }
125
+ }
126
+ }
127
+ close() {
128
+ this.closed = true;
129
+ if (this.timer)
130
+ clearTimeout(this.timer);
131
+ this.timer = null;
132
+ }
133
+ }
package/dist/index.d.ts CHANGED
@@ -2,4 +2,6 @@ export { serve } from "./serve.js";
2
2
  export { WsHub } from "./ws-hub.js";
3
3
  export type { ServeOptions, ServeHandle, UiConfig, ShortcutConfigSpec } from "./serve.js";
4
4
  export type { WsEvent } from "./ws-hub.js";
5
+ export { readLiveServerRecord, serverRecordPath } from "./server-record.js";
6
+ export type { ServerRecord, ServerKind } from "./server-record.js";
5
7
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,KAAK,EAAE,MAAM,YAAY,CAAC;AACnC,OAAO,EAAE,KAAK,EAAE,MAAM,aAAa,CAAC;AACpC,YAAY,EAAE,YAAY,EAAE,WAAW,EAAE,QAAQ,EAAE,kBAAkB,EAAE,MAAM,YAAY,CAAC;AAC1F,YAAY,EAAE,OAAO,EAAE,MAAM,aAAa,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,KAAK,EAAE,MAAM,YAAY,CAAC;AACnC,OAAO,EAAE,KAAK,EAAE,MAAM,aAAa,CAAC;AACpC,YAAY,EAAE,YAAY,EAAE,WAAW,EAAE,QAAQ,EAAE,kBAAkB,EAAE,MAAM,YAAY,CAAC;AAC1F,YAAY,EAAE,OAAO,EAAE,MAAM,aAAa,CAAC;AAC3C,OAAO,EAAE,oBAAoB,EAAE,gBAAgB,EAAE,MAAM,oBAAoB,CAAC;AAC5E,YAAY,EAAE,YAAY,EAAE,UAAU,EAAE,MAAM,oBAAoB,CAAC"}
package/dist/index.js CHANGED
@@ -1,2 +1,3 @@
1
1
  export { serve } from "./serve.js";
2
2
  export { WsHub } from "./ws-hub.js";
3
+ export { readLiveServerRecord, serverRecordPath } from "./server-record.js";
package/dist/serve.d.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { WsHub } from "./ws-hub.js";
2
+ import { type ServerKind } from "./server-record.js";
2
3
  export interface ShortcutConfigSpec {
3
4
  key: string;
4
5
  primary?: boolean;
@@ -26,6 +27,10 @@ export interface ServeOptions {
26
27
  quiet?: boolean;
27
28
  uiConfig?: UiConfig | undefined;
28
29
  isBusy?: (() => boolean) | undefined;
30
+ /** Write `.planner/.local/server.json` so other programs can find this server. Default true. */
31
+ recordServer?: boolean;
32
+ /** `standalone` only for `agent-plan serve`, where the process exists just to run this server. Default `embedded`. */
33
+ serverKind?: ServerKind;
29
34
  }
30
35
  export interface ServeHandle {
31
36
  url: string;
@@ -1 +1 @@
1
- {"version":3,"file":"serve.d.ts","sourceRoot":"","sources":["../src/serve.ts"],"names":[],"mappings":"AAYA,OAAO,EAAE,KAAK,EAAE,MAAM,aAAa,CAAC;AAyJpC,MAAM,WAAW,kBAAkB;IACjC,GAAG,EAAE,MAAM,CAAC;IACZ,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB,IAAI,CAAC,EAAE,OAAO,CAAC;IACf,IAAI,CAAC,EAAE,OAAO,CAAC;IACf,KAAK,CAAC,EAAE,OAAO,CAAC;IAChB,GAAG,CAAC,EAAE,OAAO,CAAC;CACf;AAED,MAAM,WAAW,cAAc;IAC7B,IAAI,EAAE,OAAO,GAAG,KAAK,CAAC;IACtB,QAAQ,EAAE,MAAM,CAAC;IACjB,IAAI,EAAE,MAAM,CAAC;IACb,QAAQ,EAAE,MAAM,CAAC;IACjB,MAAM,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;CAC7B;AAED,MAAM,WAAW,QAAQ;IACvB,SAAS,CAAC,EAAE,OAAO,CAAC,MAAM,CAAC,QAAQ,GAAG,MAAM,GAAG,QAAQ,GAAG,QAAQ,EAAE,kBAAkB,CAAC,CAAC,CAAC;IACzF,MAAM,CAAC,EAAE,cAAc,GAAG,SAAS,CAAC;CACrC;AA+6CD,MAAM,WAAW,YAAY;IAC3B,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,QAAQ,EAAE,MAAM,CAAC;IACjB,IAAI,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;IAC1B,SAAS,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;IAC/B,KAAK,CAAC,EAAE,OAAO,CAAC;IAChB,QAAQ,CAAC,EAAE,QAAQ,GAAG,SAAS,CAAC;IAChC,MAAM,CAAC,EAAE,CAAC,MAAM,OAAO,CAAC,GAAG,SAAS,CAAC;CACtC;AAED,MAAM,WAAW,WAAW;IAC1B,GAAG,EAAE,MAAM,CAAC;IACZ,QAAQ,EAAE,MAAM,CAAC;IACjB,MAAM,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;IAC5B,IAAI,EAAE,OAAO,GAAG,KAAK,CAAC;IACtB,QAAQ,EAAE,MAAM,CAAC;IACjB,GAAG,EAAE,KAAK,CAAC;IACX,KAAK,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,CAAC;CAC5B;AAED,wBAAsB,KAAK,CAAC,OAAO,EAAE,YAAY,GAAG,OAAO,CAAC,WAAW,CAAC,CA6EvE"}
1
+ {"version":3,"file":"serve.d.ts","sourceRoot":"","sources":["../src/serve.ts"],"names":[],"mappings":"AAYA,OAAO,EAAE,KAAK,EAAE,MAAM,aAAa,CAAC;AAGpC,OAAO,EAAyC,KAAK,UAAU,EAAE,MAAM,oBAAoB,CAAC;AAmJ5F,MAAM,WAAW,kBAAkB;IACjC,GAAG,EAAE,MAAM,CAAC;IACZ,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB,IAAI,CAAC,EAAE,OAAO,CAAC;IACf,IAAI,CAAC,EAAE,OAAO,CAAC;IACf,KAAK,CAAC,EAAE,OAAO,CAAC;IAChB,GAAG,CAAC,EAAE,OAAO,CAAC;CACf;AAED,MAAM,WAAW,cAAc;IAC7B,IAAI,EAAE,OAAO,GAAG,KAAK,CAAC;IACtB,QAAQ,EAAE,MAAM,CAAC;IACjB,IAAI,EAAE,MAAM,CAAC;IACb,QAAQ,EAAE,MAAM,CAAC;IACjB,MAAM,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;CAC7B;AAED,MAAM,WAAW,QAAQ;IACvB,SAAS,CAAC,EAAE,OAAO,CAAC,MAAM,CAAC,QAAQ,GAAG,MAAM,GAAG,QAAQ,GAAG,QAAQ,EAAE,kBAAkB,CAAC,CAAC,CAAC;IACzF,MAAM,CAAC,EAAE,cAAc,GAAG,SAAS,CAAC;CACrC;AAw7CD,MAAM,WAAW,YAAY;IAC3B,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,QAAQ,EAAE,MAAM,CAAC;IACjB,IAAI,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;IAC1B,SAAS,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;IAC/B,KAAK,CAAC,EAAE,OAAO,CAAC;IAChB,QAAQ,CAAC,EAAE,QAAQ,GAAG,SAAS,CAAC;IAChC,MAAM,CAAC,EAAE,CAAC,MAAM,OAAO,CAAC,GAAG,SAAS,CAAC;IACrC,gGAAgG;IAChG,YAAY,CAAC,EAAE,OAAO,CAAC;IACvB,sHAAsH;IACtH,UAAU,CAAC,EAAE,UAAU,CAAC;CACzB;AAED,MAAM,WAAW,WAAW;IAC1B,GAAG,EAAE,MAAM,CAAC;IACZ,QAAQ,EAAE,MAAM,CAAC;IACjB,MAAM,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;IAC5B,IAAI,EAAE,OAAO,GAAG,KAAK,CAAC;IACtB,QAAQ,EAAE,MAAM,CAAC;IACjB,GAAG,EAAE,KAAK,CAAC;IACX,KAAK,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,CAAC;CAC5B;AAED,wBAAsB,KAAK,CAAC,OAAO,EAAE,YAAY,GAAG,OAAO,CAAC,WAAW,CAAC,CAmGvE"}
package/dist/serve.js CHANGED
@@ -7,10 +7,12 @@ import { fileURLToPath } from "node:url";
7
7
  import { createAdaptorServer } from "@hono/node-server";
8
8
  import { Hono } from "hono";
9
9
  import { cors } from "hono/cors";
10
- import { ExportService, PlanStore, PlanStoreError, PlanStaleWriteError, PlanWriterBusyError, createFeatureId, createPhaseId, createChecklistItemId, createRequirementId, createTaskId, findPhaseByRef, normalizeSlug, withFeatureLock, needsMotivation, checkExplicitTaskStart, recommendNextTask, recommendNextWork, reconcileRequirementMacroTasks, RequirementMacroTaskError, packageVersionFromModule, ACCEPTED_DECISION_RAW_REPLACEMENT_DISABLED_MESSAGE, ACCEPTED_DECISION_SEMANTIC_MUTATION_REQUIRED_ERROR_CODE, deleteFeatureCascade, nowISO, applyTaskLifecycleDates, resolveAcceptedDecisionTarget } from "@agent-plan/core";
10
+ import { API_V1_VERSION, ExportService, PlanStore, PlanStoreError, PlanStaleWriteError, PlanWriterBusyError, createFeatureId, createPhaseId, createChecklistItemId, createRequirementId, createTaskId, findPhaseByRef, normalizeSlug, withFeatureLock, needsMotivation, checkExplicitTaskStart, recommendNextTask, recommendNextWork, reconcileRequirementMacroTasks, RequirementMacroTaskError, packageVersionFromModule, ACCEPTED_DECISION_RAW_REPLACEMENT_DISABLED_MESSAGE, ACCEPTED_DECISION_SEMANTIC_MUTATION_REQUIRED_ERROR_CODE, deleteFeatureCascade, nowISO, applyTaskLifecycleDates, resolveAcceptedDecisionTarget } from "@agent-plan/core";
11
11
  import { WsHub } from "./ws-hub.js";
12
+ import { API_V1_BASE_PATH, createApiV1App } from "./api-v1.js";
13
+ import { EntityEventDetector } from "./entity-events.js";
14
+ import { removeServerRecord, writeServerRecord } from "./server-record.js";
12
15
  // ─── Watcher ────────────────────────────────────────────────────────────
13
- let watcherAbort = null;
14
16
  const SERVER_PACKAGE = packageVersionFromModule(import.meta.url, "@agent-plan/server");
15
17
  /** Resolve requirement phase refs before persistence so requirements never dangle. */
16
18
  async function resolveRequirementPhaseIds(store, linkedPhaseIds) {
@@ -112,16 +114,16 @@ function phaseGovernanceReady(phase) {
112
114
  function fromWebUi(c) {
113
115
  return c.req.header("X-Planner-Source") === "web-ui";
114
116
  }
115
- function startWatcher(planRoot, hubRef) {
116
- stopWatcher();
117
+ /** Watch the planner folder. Returns a function that stops this watcher only, so several servers in one process never stop each other's. */
118
+ function startWatcher(planRoot, hubRef, onChange) {
117
119
  if (!existsSync(planRoot))
118
- return;
120
+ return () => { };
119
121
  const ac = new AbortController();
120
- watcherAbort = ac;
121
122
  try {
122
123
  const watcher = watch(planRoot, { recursive: true, signal: ac.signal }, (_event, filename) => {
123
124
  if (filename && !filename.includes(".tmp.")) {
124
125
  hubRef.current?.broadcast({ type: "file-changed", data: { filename } });
126
+ onChange();
125
127
  }
126
128
  });
127
129
  watcher.on("error", () => {
@@ -132,12 +134,9 @@ function startWatcher(planRoot, hubRef) {
132
134
  catch {
133
135
  // recursive watch may fail on some systems
134
136
  }
137
+ return () => ac.abort();
135
138
  }
136
- function stopWatcher() {
137
- watcherAbort?.abort();
138
- watcherAbort = null;
139
- }
140
- function createApiApp(store, hubRef, apiPrefix = "", uiConfig, isBusy) {
139
+ function createApiApp(store, hubRef, apiPrefix = "", uiConfig, isBusy, onExternalChange) {
141
140
  const hub = () => hubRef.current;
142
141
  const route = (path) => `${apiPrefix}${path}`;
143
142
  const app = new Hono();
@@ -171,8 +170,12 @@ function createApiApp(store, hubRef, apiPrefix = "", uiConfig, isBusy) {
171
170
  app.post(route("/internal/notify"), (c) => {
172
171
  hub()?.broadcast({ type: "plan-rendered", data: {} });
173
172
  hub()?.broadcast({ type: "file-changed", data: { filename: "external-notify" } });
173
+ onExternalChange?.();
174
174
  return c.json({ ok: true });
175
175
  });
176
+ // The outside API has its own versioned contract and its own busy/error replies,
177
+ // so it is mounted before the web UI's busy middleware below.
178
+ app.route(API_V1_BASE_PATH, createApiV1App(store, isBusy));
176
179
  // While the agent is mutating .planner/ files, avoid serving possibly-inconsistent data.
177
180
  // GET requests get a 503 busy signal the UI retries; mutations are rejected.
178
181
  app.use("*", async (c, next) => {
@@ -1572,13 +1575,18 @@ function createApiApp(store, hubRef, apiPrefix = "", uiConfig, isBusy) {
1572
1575
  return c.json({ cleared: true });
1573
1576
  });
1574
1577
  // ── Health ───────────────────────────────────────────────────────
1575
- app.get(route("/health"), (c) => c.json({ status: "ok", root: store.root }));
1578
+ const health = (c) => c.json({ status: "ok", root: store.root, apiVersion: API_V1_VERSION, pid: process.pid, version: SERVER_PACKAGE.version });
1579
+ app.get(route("/health"), health);
1580
+ // With the web UI bundled the API sits under /api and unknown paths fall through to the UI,
1581
+ // so a program probing the server must also find /health at the root.
1582
+ if (apiPrefix)
1583
+ app.get("/health", health);
1576
1584
  app.get(route("/ui-config"), (c) => c.json(uiConfig ?? {}));
1577
1585
  return app;
1578
1586
  }
1579
1587
  // ─── Serve static files (SPA) ────────────────────────────────────────────
1580
- function createSpaApp(store, hubRef, staticDir, uiConfig, isBusy) {
1581
- const app = createApiApp(store, hubRef, staticDir ? "/api" : "", uiConfig, isBusy);
1588
+ function createSpaApp(store, hubRef, staticDir, uiConfig, isBusy, onExternalChange) {
1589
+ const app = createApiApp(store, hubRef, staticDir ? "/api" : "", uiConfig, isBusy, onExternalChange);
1582
1590
  if (!staticDir) {
1583
1591
  return app;
1584
1592
  }
@@ -1655,7 +1663,7 @@ function resolveBundledStaticDir() {
1655
1663
  }
1656
1664
  }
1657
1665
  export async function serve(options) {
1658
- const { port = 3030, planRoot, host = "127.0.0.1" } = options;
1666
+ const { port = 3030, planRoot, host = "127.0.0.1", recordServer = true, serverKind = "embedded" } = options;
1659
1667
  const store = new PlanStore(planRoot);
1660
1668
  const runtimeUiConfig = { ...(options.uiConfig ?? {}) };
1661
1669
  if (!(await store.exists())) {
@@ -1668,7 +1676,10 @@ export async function serve(options) {
1668
1676
  // Default to the bundled web UI when the caller doesn't pass staticDir. Pass
1669
1677
  // an empty string ("") to force API-only even when the bundle is present.
1670
1678
  const staticDir = options.staticDir ?? resolveBundledStaticDir();
1671
- const app = createSpaApp(store, hubRef, staticDir, runtimeUiConfig, options.isBusy);
1679
+ // Detects exactly which entities changed, including writes by other processes (MCP).
1680
+ const detector = new EntityEventDetector(store, (change) => hubRef.current?.broadcast({ type: "entity-changed", data: change }));
1681
+ await detector.prime();
1682
+ const app = createSpaApp(store, hubRef, staticDir, runtimeUiConfig, options.isBusy, () => detector.signal());
1672
1683
  return new Promise((resolve, reject) => {
1673
1684
  // Create the Node HTTP server without listening yet
1674
1685
  const server = createAdaptorServer({ fetch: app.fetch, hostname: host });
@@ -1701,8 +1712,8 @@ export async function serve(options) {
1701
1712
  if (lanUrl)
1702
1713
  console.log(`[plan-server] lan url: ${lanUrl}`);
1703
1714
  }
1704
- startWatcher(planRoot, hubRef);
1705
- resolve({
1715
+ const stopWatcher = startWatcher(planRoot, hubRef, () => detector.signal());
1716
+ const handle = {
1706
1717
  url: localUrl,
1707
1718
  localUrl,
1708
1719
  lanUrl,
@@ -1711,6 +1722,9 @@ export async function serve(options) {
1711
1722
  hub,
1712
1723
  close: async () => {
1713
1724
  stopWatcher();
1725
+ detector.close();
1726
+ if (recordServer)
1727
+ await removeServerRecord(store.root, { pid: process.pid, localUrl });
1714
1728
  hub.close();
1715
1729
  // Force-close any lingering sockets (browser WebSocket / keep-alive),
1716
1730
  // otherwise server.close() hangs until the kernel times them out.
@@ -1718,7 +1732,24 @@ export async function serve(options) {
1718
1732
  anyServer.closeAllConnections?.();
1719
1733
  await new Promise((r) => anyServer.close(() => r()));
1720
1734
  },
1721
- });
1735
+ };
1736
+ if (!recordServer) {
1737
+ resolve(handle);
1738
+ return;
1739
+ }
1740
+ void writeServerRecord(store.root, {
1741
+ kind: serverKind,
1742
+ pid: process.pid,
1743
+ url: handle.url,
1744
+ localUrl: handle.localUrl,
1745
+ lanUrl: handle.lanUrl ?? null,
1746
+ host: handle.bindHost,
1747
+ port: actualPort,
1748
+ root: store.root,
1749
+ startedAt: new Date().toISOString(),
1750
+ version: SERVER_PACKAGE.version,
1751
+ apiVersion: API_V1_VERSION,
1752
+ }).finally(() => resolve(handle));
1722
1753
  });
1723
1754
  });
1724
1755
  }
@@ -0,0 +1,44 @@
1
+ /**
2
+ * The record of a running planner server: `<planner folder>/.local/server.json`.
3
+ *
4
+ * Several things start a server (`agent-plan serve`, the MCP `planner-web` and
5
+ * `planner-load`, the Pi adapter) and until now nothing said that one was
6
+ * already running for a folder. serve() writes this record, so every starter
7
+ * gets it, and a program that wants to reuse a server reads it with
8
+ * readLiveServerRecord(). The first live server owns the record.
9
+ */
10
+ /**
11
+ * How the server was started. `standalone` is `agent-plan serve`: the whole
12
+ * process is the server, so it is safe to stop by signalling its pid.
13
+ * `embedded` is a server inside another program (an agent's MCP server, the Pi
14
+ * adapter): signalling that pid would kill the host, so only its owner stops it.
15
+ */
16
+ export type ServerKind = "standalone" | "embedded";
17
+ export interface ServerRecord {
18
+ kind: ServerKind;
19
+ pid: number;
20
+ url: string;
21
+ localUrl: string;
22
+ lanUrl: string | null;
23
+ host: string;
24
+ port: number;
25
+ root: string;
26
+ startedAt: string;
27
+ version: string;
28
+ apiVersion: string;
29
+ }
30
+ export declare function serverRecordPath(planRoot: string): string;
31
+ /** The record of a server that is really running for this folder, or null. A stale record is deleted. */
32
+ export declare function readLiveServerRecord(planRoot: string): Promise<ServerRecord | null>;
33
+ /**
34
+ * Write the record unless another live server already owns it. Never throws:
35
+ * a folder that cannot be written must not stop the server from running.
36
+ * Returns true when this server now owns the record.
37
+ */
38
+ export declare function writeServerRecord(planRoot: string, record: ServerRecord): Promise<boolean>;
39
+ /** Remove the record, but only when it describes this very server (pid and address). Never throws. */
40
+ export declare function removeServerRecord(planRoot: string, owner: {
41
+ pid: number;
42
+ localUrl: string;
43
+ }): Promise<void>;
44
+ //# sourceMappingURL=server-record.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"server-record.d.ts","sourceRoot":"","sources":["../src/server-record.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAKH;;;;;GAKG;AACH,MAAM,MAAM,UAAU,GAAG,YAAY,GAAG,UAAU,CAAC;AAEnD,MAAM,WAAW,YAAY;IAC3B,IAAI,EAAE,UAAU,CAAC;IACjB,GAAG,EAAE,MAAM,CAAC;IACZ,GAAG,EAAE,MAAM,CAAC;IACZ,QAAQ,EAAE,MAAM,CAAC;IACjB,MAAM,EAAE,MAAM,GAAG,IAAI,CAAC;IACtB,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,MAAM,CAAC;IACb,SAAS,EAAE,MAAM,CAAC;IAClB,OAAO,EAAE,MAAM,CAAC;IAChB,UAAU,EAAE,MAAM,CAAC;CACpB;AAKD,wBAAgB,gBAAgB,CAAC,QAAQ,EAAE,MAAM,GAAG,MAAM,CAEzD;AAuCD,yGAAyG;AACzG,wBAAsB,oBAAoB,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,YAAY,GAAG,IAAI,CAAC,CAMzF;AAED;;;;GAIG;AACH,wBAAsB,iBAAiB,CAAC,QAAQ,EAAE,MAAM,EAAE,MAAM,EAAE,YAAY,GAAG,OAAO,CAAC,OAAO,CAAC,CAchG;AAED,sGAAsG;AACtG,wBAAsB,kBAAkB,CAAC,QAAQ,EAAE,MAAM,EAAE,KAAK,EAAE;IAAE,GAAG,EAAE,MAAM,CAAC;IAAC,QAAQ,EAAE,MAAM,CAAA;CAAE,GAAG,OAAO,CAAC,IAAI,CAAC,CAOlH"}
@@ -0,0 +1,97 @@
1
+ /**
2
+ * The record of a running planner server: `<planner folder>/.local/server.json`.
3
+ *
4
+ * Several things start a server (`agent-plan serve`, the MCP `planner-web` and
5
+ * `planner-load`, the Pi adapter) and until now nothing said that one was
6
+ * already running for a folder. serve() writes this record, so every starter
7
+ * gets it, and a program that wants to reuse a server reads it with
8
+ * readLiveServerRecord(). The first live server owns the record.
9
+ */
10
+ import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
11
+ import { dirname, join, resolve } from "node:path";
12
+ /** How long a liveness check waits for /health before calling the record stale. */
13
+ const HEALTH_TIMEOUT_MS = 1000;
14
+ export function serverRecordPath(planRoot) {
15
+ return join(planRoot, ".local", "server.json");
16
+ }
17
+ function isServerRecord(value) {
18
+ if (value === null || typeof value !== "object")
19
+ return false;
20
+ const record = value;
21
+ return typeof record.pid === "number" && typeof record.localUrl === "string" && typeof record.root === "string"
22
+ && (record.kind === "standalone" || record.kind === "embedded");
23
+ }
24
+ async function readRecordFile(planRoot) {
25
+ try {
26
+ const parsed = JSON.parse(await readFile(serverRecordPath(planRoot), "utf-8"));
27
+ return isServerRecord(parsed) ? parsed : null;
28
+ }
29
+ catch {
30
+ return null;
31
+ }
32
+ }
33
+ function processExists(pid) {
34
+ try {
35
+ process.kill(pid, 0);
36
+ return true;
37
+ }
38
+ catch (error) {
39
+ // EPERM means the process exists but belongs to someone else.
40
+ return error.code === "EPERM";
41
+ }
42
+ }
43
+ async function answersHealth(record) {
44
+ try {
45
+ const response = await fetch(`${record.localUrl.replace(/\/$/, "")}/health`, { signal: AbortSignal.timeout(HEALTH_TIMEOUT_MS) });
46
+ if (!response.ok)
47
+ return false;
48
+ const body = (await response.json());
49
+ return typeof body.root === "string" && resolve(body.root) === resolve(record.root);
50
+ }
51
+ catch {
52
+ return false;
53
+ }
54
+ }
55
+ /** The record of a server that is really running for this folder, or null. A stale record is deleted. */
56
+ export async function readLiveServerRecord(planRoot) {
57
+ const record = await readRecordFile(planRoot);
58
+ if (!record)
59
+ return null;
60
+ if (processExists(record.pid) && (await answersHealth(record)))
61
+ return record;
62
+ await rm(serverRecordPath(planRoot), { force: true }).catch(() => { });
63
+ return null;
64
+ }
65
+ /**
66
+ * Write the record unless another live server already owns it. Never throws:
67
+ * a folder that cannot be written must not stop the server from running.
68
+ * Returns true when this server now owns the record.
69
+ */
70
+ export async function writeServerRecord(planRoot, record) {
71
+ try {
72
+ const existing = await readLiveServerRecord(planRoot);
73
+ // Compare the address, not the pid: one process can run several servers.
74
+ if (existing && existing.localUrl !== record.localUrl)
75
+ return false;
76
+ const path = serverRecordPath(planRoot);
77
+ await mkdir(dirname(path), { recursive: true });
78
+ const temporary = `${path}.${process.pid}.tmp`;
79
+ await writeFile(temporary, `${JSON.stringify(record, null, 2)}\n`);
80
+ await rename(temporary, path);
81
+ return true;
82
+ }
83
+ catch {
84
+ return false;
85
+ }
86
+ }
87
+ /** Remove the record, but only when it describes this very server (pid and address). Never throws. */
88
+ export async function removeServerRecord(planRoot, owner) {
89
+ try {
90
+ const record = await readRecordFile(planRoot);
91
+ if (record?.pid === owner.pid && record.localUrl === owner.localUrl)
92
+ await rm(serverRecordPath(planRoot), { force: true });
93
+ }
94
+ catch {
95
+ // Nothing useful to do: the record is advisory.
96
+ }
97
+ }
package/dist/ws-hub.d.ts CHANGED
@@ -5,6 +5,7 @@
5
5
  * All plan mutations broadcast events so connected clients stay in sync.
6
6
  */
7
7
  import type { Server } from "node:http";
8
+ import type { EntityChangedData } from "./entity-events.js";
8
9
  export type WsEvent = {
9
10
  type: "file-changed";
10
11
  data: {
@@ -35,6 +36,9 @@ export type WsEvent = {
35
36
  data: {
36
37
  phaseId: string;
37
38
  };
39
+ } | {
40
+ type: "entity-changed";
41
+ data: EntityChangedData;
38
42
  };
39
43
  export declare class WsHub {
40
44
  private wss;
@@ -1 +1 @@
1
- {"version":3,"file":"ws-hub.d.ts","sourceRoot":"","sources":["../src/ws-hub.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAGH,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,WAAW,CAAC;AAExC,MAAM,MAAM,OAAO,GACf;IAAE,IAAI,EAAE,cAAc,CAAC;IAAC,IAAI,EAAE;QAAE,QAAQ,EAAE,MAAM,CAAA;KAAE,CAAA;CAAE,GACpD;IAAE,IAAI,EAAE,iBAAiB,CAAC;IAAC,IAAI,EAAE,OAAO,CAAA;CAAE,GAC1C;IAAE,IAAI,EAAE,sBAAsB,CAAC;IAAC,IAAI,EAAE,OAAO,CAAA;CAAE,GAC/C;IAAE,IAAI,EAAE,kBAAkB,CAAC;IAAC,IAAI,EAAE,OAAO,CAAA;CAAE,GAC3C;IAAE,IAAI,EAAE,gBAAgB,CAAC;IAAC,IAAI,EAAE,OAAO,CAAA;CAAE,GACzC;IAAE,IAAI,EAAE,eAAe,CAAC;IAAC,IAAI,EAAE,OAAO,CAAA;CAAE,GACxC;IAAE,IAAI,EAAE,gBAAgB,CAAC;IAAC,IAAI,EAAE;QAAE,OAAO,EAAE,MAAM,CAAA;KAAE,CAAA;CAAE,GACrD;IAAE,IAAI,EAAE,gBAAgB,CAAC;IAAC,IAAI,EAAE;QAAE,OAAO,EAAE,MAAM,CAAA;KAAE,CAAA;CAAE,CAAC;AAE1D,qBAAa,KAAK;IAChB,OAAO,CAAC,GAAG,CAAkB;IAC7B,OAAO,CAAC,OAAO,CAAwB;IACvC,OAAO,CAAC,KAAK,CAAU;gBAEX,MAAM,EAAE,MAAM,EAAE,KAAK,UAAQ,EAAE,OAAO,CAAC,EAAE,CAAC,GAAG,EAAE,KAAK,KAAK,IAAI;IA0CzE,SAAS,CAAC,KAAK,EAAE,OAAO,GAAG,IAAI;IAa/B,KAAK,IAAI,IAAI;CASd"}
1
+ {"version":3,"file":"ws-hub.d.ts","sourceRoot":"","sources":["../src/ws-hub.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAGH,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,WAAW,CAAC;AACxC,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,oBAAoB,CAAC;AAE5D,MAAM,MAAM,OAAO,GACf;IAAE,IAAI,EAAE,cAAc,CAAC;IAAC,IAAI,EAAE;QAAE,QAAQ,EAAE,MAAM,CAAA;KAAE,CAAA;CAAE,GACpD;IAAE,IAAI,EAAE,iBAAiB,CAAC;IAAC,IAAI,EAAE,OAAO,CAAA;CAAE,GAC1C;IAAE,IAAI,EAAE,sBAAsB,CAAC;IAAC,IAAI,EAAE,OAAO,CAAA;CAAE,GAC/C;IAAE,IAAI,EAAE,kBAAkB,CAAC;IAAC,IAAI,EAAE,OAAO,CAAA;CAAE,GAC3C;IAAE,IAAI,EAAE,gBAAgB,CAAC;IAAC,IAAI,EAAE,OAAO,CAAA;CAAE,GACzC;IAAE,IAAI,EAAE,eAAe,CAAC;IAAC,IAAI,EAAE,OAAO,CAAA;CAAE,GACxC;IAAE,IAAI,EAAE,gBAAgB,CAAC;IAAC,IAAI,EAAE;QAAE,OAAO,EAAE,MAAM,CAAA;KAAE,CAAA;CAAE,GACrD;IAAE,IAAI,EAAE,gBAAgB,CAAC;IAAC,IAAI,EAAE;QAAE,OAAO,EAAE,MAAM,CAAA;KAAE,CAAA;CAAE,GACrD;IAAE,IAAI,EAAE,gBAAgB,CAAC;IAAC,IAAI,EAAE,iBAAiB,CAAA;CAAE,CAAC;AAExD,qBAAa,KAAK;IAChB,OAAO,CAAC,GAAG,CAAkB;IAC7B,OAAO,CAAC,OAAO,CAAwB;IACvC,OAAO,CAAC,KAAK,CAAU;gBAEX,MAAM,EAAE,MAAM,EAAE,KAAK,UAAQ,EAAE,OAAO,CAAC,EAAE,CAAC,GAAG,EAAE,KAAK,KAAK,IAAI;IA0CzE,SAAS,CAAC,KAAK,EAAE,OAAO,GAAG,IAAI;IAa/B,KAAK,IAAI,IAAI;CASd"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agent-plan/server",
3
- "version": "0.4.1",
3
+ "version": "0.4.3",
4
4
  "private": false,
5
5
  "description": "Local HTTP/WebSocket server for Agent Plan dashboards and planner APIs.",
6
6
  "license": "MIT",
@@ -41,7 +41,7 @@
41
41
  "@hono/node-server": "^1.14.0",
42
42
  "hono": "^4.7.0",
43
43
  "ws": "^8.18.0",
44
- "@agent-plan/core": "0.4.1"
44
+ "@agent-plan/core": "0.4.3"
45
45
  },
46
46
  "devDependencies": {
47
47
  "@types/ws": "^8.18.0"