portable-agent-layer 0.71.0 → 0.73.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/package.json +10 -7
- package/src/cli/migrate.ts +1 -1
- package/src/cli/server.ts +7 -0
- package/src/cli/skill.ts +1 -1
- package/src/hooks/CompactRecover.ts +28 -86
- package/src/hooks/LedgerUnapplied.ts +3 -28
- package/src/hooks/LoadContext.ts +33 -60
- package/src/hooks/SecurityValidator.ts +16 -109
- package/src/hooks/handlers/agenda.ts +195 -7
- package/src/hooks/handlers/failure-principle.ts +19 -44
- package/src/hooks/handlers/session-intelligence.ts +13 -70
- package/src/hooks/lib/agenda-store.ts +2 -0
- package/src/hooks/lib/capture-store.ts +103 -0
- package/src/hooks/lib/compact-recall.ts +89 -0
- package/src/hooks/lib/failure-principle.ts +98 -0
- package/src/hooks/lib/goal-links.ts +83 -0
- package/src/hooks/lib/ledger-hook.ts +35 -0
- package/src/hooks/lib/ledger.ts +48 -1
- package/src/hooks/lib/models.ts +1 -1
- package/src/hooks/lib/projects.ts +5 -0
- package/src/hooks/lib/security-gate.ts +159 -0
- package/src/hooks/lib/session-context.ts +74 -0
- package/src/hooks/lib/settings.ts +2 -0
- package/src/hooks/lib/telos-goals.ts +8 -2
- package/src/hooks/lib/token-usage.ts +2 -0
- package/src/tools/agent/algorithm-reflect.ts +28 -97
- package/src/tools/agent/analyze.ts +19 -120
- package/src/tools/agent/handoff-note.ts +29 -77
- package/src/tools/agent/project.ts +34 -153
- package/src/tools/agent/relationship-note.ts +27 -46
- package/src/tools/agent/synthesize.ts +1 -1
- package/src/tools/agent/thread.ts +43 -123
- package/src/tools/control-room/attention.ts +146 -0
- package/src/tools/control-room/data.ts +78 -7
- package/src/tools/control-room/detail.ts +158 -0
- package/src/tools/control-room/matrix.ts +92 -20
- package/src/tools/control-room/prefs.ts +96 -0
- package/src/tools/control-room/server-config.ts +8 -0
- package/src/tools/control-room/server.ts +196 -11
- package/src/tools/control-room/snooze.ts +65 -0
- package/src/tools/control-room/static.ts +68 -0
- package/src/tools/control-room/ui/app.tsx +196 -50
- package/src/tools/control-room/ui/attention-bell.tsx +118 -0
- package/src/tools/control-room/ui/components/README.md +18 -0
- package/src/tools/control-room/ui/components/badge.tsx +38 -0
- package/src/tools/control-room/ui/components/button.tsx +43 -0
- package/src/tools/control-room/ui/components/card.tsx +45 -0
- package/src/tools/control-room/ui/components/input.tsx +24 -0
- package/src/tools/control-room/ui/components/label.tsx +18 -0
- package/src/tools/control-room/ui/components/popover.tsx +37 -0
- package/src/tools/control-room/ui/components/separator.tsx +25 -0
- package/src/tools/control-room/ui/components/skeleton.tsx +14 -0
- package/src/tools/control-room/ui/components/switch.tsx +27 -0
- package/src/tools/control-room/ui/components/table.tsx +60 -0
- package/src/tools/control-room/ui/components/toggle-group.tsx +38 -0
- package/src/tools/control-room/ui/dist/assets/index-BoQjFpzV.js +49 -0
- package/src/tools/control-room/ui/dist/assets/index-Dncp2bYg.css +2 -0
- package/src/tools/control-room/ui/dist/index.html +19 -0
- package/src/tools/control-room/ui/format.ts +0 -12
- package/src/tools/control-room/ui/frame.tsx +125 -0
- package/src/tools/control-room/ui/index.html +2 -2
- package/src/tools/control-room/ui/lib/api.ts +27 -0
- package/src/tools/control-room/ui/lib/cn.ts +6 -0
- package/src/tools/control-room/ui/lib/write.ts +43 -0
- package/src/tools/control-room/ui/package.json +28 -0
- package/src/tools/control-room/ui/parts.tsx +235 -0
- package/src/tools/control-room/ui/screens/log.tsx +274 -0
- package/src/tools/control-room/ui/screens/project-detail.tsx +302 -0
- package/src/tools/control-room/ui/screens/projects.tsx +128 -0
- package/src/tools/control-room/ui/screens/settings.tsx +205 -0
- package/src/tools/control-room/ui/screens/today.tsx +391 -0
- package/src/tools/control-room/ui/theme.css +127 -0
- package/src/tools/control-room/ui/vite.config.ts +36 -0
- package/src/tools/control-room/writes.ts +106 -0
- package/src/tools/ledger/outcomes.ts +9 -0
- package/src/tools/ledger/query.ts +2 -0
- package/src/tools/ledger/view.ts +34 -10
- package/src/tools/lib/algorithm-reflect.ts +84 -0
- package/src/tools/lib/analyze-report.ts +120 -0
- package/src/tools/lib/handoff-note.ts +102 -0
- package/src/tools/lib/note-flags.ts +59 -0
- package/src/tools/lib/project-isc.ts +212 -0
- package/src/tools/lib/relationship-reflect.ts +402 -0
- package/src/tools/lib/self-model.ts +499 -0
- package/src/tools/lib/session-usage.ts +216 -0
- package/src/tools/lib/skill-doctor.ts +457 -0
- package/src/tools/lib/thread.ts +119 -0
- package/src/tools/lib/token-report.ts +173 -0
- package/src/tools/lib/transcript-usage.ts +42 -0
- package/src/tools/lib/usage-buckets.ts +329 -0
- package/src/tools/relationship-reflect.ts +48 -412
- package/src/tools/self-model.ts +76 -558
- package/src/tools/session-summary.ts +8 -215
- package/src/tools/skill-doctor.ts +9 -444
- package/src/tools/token-cost.ts +18 -428
- package/src/tools/control-room/ui/agenda.tsx +0 -43
- package/src/tools/control-room/ui/agents.tsx +0 -67
- package/src/tools/control-room/ui/app.css +0 -857
- package/src/tools/control-room/ui/board.tsx +0 -82
- package/src/tools/control-room/ui/handoffs.tsx +0 -37
- package/src/tools/control-room/ui/ledger.tsx +0 -136
- package/src/tools/control-room/ui/matrix.tsx +0 -117
- package/src/tools/control-room/ui/panel.tsx +0 -60
- package/src/tools/control-room/ui/signal.tsx +0 -161
|
@@ -9,14 +9,35 @@
|
|
|
9
9
|
import { loadMachine } from "../../hooks/lib/machine";
|
|
10
10
|
import { readAllProjects } from "../../hooks/lib/projects";
|
|
11
11
|
import { isServesKind, setServes } from "../../hooks/lib/serves";
|
|
12
|
+
import { PAGE_OUTCOMES } from "../ledger/outcomes";
|
|
12
13
|
import { type LedgerFilter, ledgerFiles, parseSince } from "../ledger/query";
|
|
13
14
|
import { ledgerView } from "../ledger/view";
|
|
14
|
-
import {
|
|
15
|
+
import { attention, markRead } from "./attention";
|
|
16
|
+
import { agenda, agentsAtWork, board, handoffs, signal, summary } from "./data";
|
|
17
|
+
import { projectDetail } from "./detail";
|
|
15
18
|
import { matrix } from "./matrix";
|
|
16
|
-
import
|
|
19
|
+
import { type PrefUpdate, readPrefs, validatePrefs, writePrefs } from "./prefs";
|
|
20
|
+
import { DEFAULT_PORT, LOOPBACK } from "./server-config";
|
|
21
|
+
import { MAX_SNOOZE_DAYS, setSnooze } from "./snooze";
|
|
22
|
+
import { indexHtml, staticAsset } from "./static";
|
|
23
|
+
import {
|
|
24
|
+
type InstallSettings,
|
|
25
|
+
isQuadrant,
|
|
26
|
+
QUADRANTS,
|
|
27
|
+
readInstallSettings,
|
|
28
|
+
setIscStatus,
|
|
29
|
+
setPlacement,
|
|
30
|
+
type WriteOutcome,
|
|
31
|
+
writeInstallSettings,
|
|
32
|
+
} from "./writes";
|
|
17
33
|
|
|
18
|
-
export
|
|
19
|
-
|
|
34
|
+
export { DEFAULT_PORT, LOOPBACK };
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Every path the single-page app owns. Listed rather than wildcarded, because a
|
|
38
|
+
* blanket "/*" outranks the fetch handler and would swallow /api as well.
|
|
39
|
+
*/
|
|
40
|
+
const PAGE_ROUTES = ["/", "/projects", "/projects/:slug", "/log", "/settings"];
|
|
20
41
|
|
|
21
42
|
export interface ServerStatus {
|
|
22
43
|
pid: number;
|
|
@@ -30,11 +51,25 @@ function json(body: unknown, status = 200): Response {
|
|
|
30
51
|
return Response.json(body, { status });
|
|
31
52
|
}
|
|
32
53
|
|
|
33
|
-
|
|
54
|
+
const AUTHORITIES: readonly string[] = ["user", "agent"];
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Free-form on the record, so the page may name any of them: a runtime or
|
|
58
|
+
* machine label PAL has never seen simply matches nothing.
|
|
59
|
+
*/
|
|
60
|
+
const PASSTHROUGH = ["project", "runtime", "machine", "actor", "tool"] as const;
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* A window the page cannot parse is an error, not the whole ledger. The same
|
|
64
|
+
* goes for a closed set it spells wrong — silently answering with every row
|
|
65
|
+
* would read as "no such filter" rather than "no such value".
|
|
66
|
+
*/
|
|
34
67
|
function filterFromQuery(params: URLSearchParams): LedgerFilter | string {
|
|
35
68
|
const filter: LedgerFilter = {};
|
|
36
|
-
const
|
|
37
|
-
|
|
69
|
+
for (const key of PASSTHROUGH) {
|
|
70
|
+
const value = params.get(key);
|
|
71
|
+
if (value) filter[key] = value;
|
|
72
|
+
}
|
|
38
73
|
for (const key of ["since", "until"] as const) {
|
|
39
74
|
const spec = params.get(key);
|
|
40
75
|
if (!spec) continue;
|
|
@@ -42,6 +77,18 @@ function filterFromQuery(params: URLSearchParams): LedgerFilter | string {
|
|
|
42
77
|
if (!at) return `Unrecognised ${key}: ${spec}`;
|
|
43
78
|
filter[key] = at;
|
|
44
79
|
}
|
|
80
|
+
const outcome = params.get("outcome");
|
|
81
|
+
if (outcome) {
|
|
82
|
+
if (!(PAGE_OUTCOMES as readonly string[]).includes(outcome)) {
|
|
83
|
+
return `Unrecognised outcome: ${outcome}`;
|
|
84
|
+
}
|
|
85
|
+
filter.outcome = outcome;
|
|
86
|
+
}
|
|
87
|
+
const authority = params.get("authority");
|
|
88
|
+
if (authority) {
|
|
89
|
+
if (!AUTHORITIES.includes(authority)) return `Unrecognised authority: ${authority}`;
|
|
90
|
+
filter.authority = authority;
|
|
91
|
+
}
|
|
45
92
|
return filter;
|
|
46
93
|
}
|
|
47
94
|
|
|
@@ -52,6 +99,23 @@ function projects(): Response {
|
|
|
52
99
|
return json(slugs.map((slug) => ({ slug })));
|
|
53
100
|
}
|
|
54
101
|
|
|
102
|
+
function oneProject(slug: string | null): Response {
|
|
103
|
+
if (!slug) return json({ error: "slug is required" }, 400);
|
|
104
|
+
const detail = projectDetail(slug);
|
|
105
|
+
return detail ? json(detail) : json({ error: `no such project: ${slug}` }, 404);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const SUMMARY_DAYS_DEFAULT = 14;
|
|
109
|
+
const SUMMARY_DAYS_MAX = 365;
|
|
110
|
+
|
|
111
|
+
function summaryFor(spec: string | null): Response {
|
|
112
|
+
const days = spec ? Number(spec) : SUMMARY_DAYS_DEFAULT;
|
|
113
|
+
if (!Number.isInteger(days) || days < 1 || days > SUMMARY_DAYS_MAX) {
|
|
114
|
+
return json({ error: `days must be a whole number of 1..${SUMMARY_DAYS_MAX}` }, 400);
|
|
115
|
+
}
|
|
116
|
+
return json(summary(days));
|
|
117
|
+
}
|
|
118
|
+
|
|
55
119
|
function withFilter(url: URL, view: (filter: LedgerFilter) => unknown): Response {
|
|
56
120
|
const filter = filterFromQuery(url.searchParams);
|
|
57
121
|
if (typeof filter === "string") return json({ error: filter }, 400);
|
|
@@ -99,19 +163,130 @@ async function overrideServes(request: Request): Promise<Response> {
|
|
|
99
163
|
return json({ project, serves, by: "user" });
|
|
100
164
|
}
|
|
101
165
|
|
|
166
|
+
async function readBody(request: Request): Promise<Record<string, unknown> | null> {
|
|
167
|
+
try {
|
|
168
|
+
return ((await request.json()) ?? {}) as Record<string, unknown>;
|
|
169
|
+
} catch {
|
|
170
|
+
return null;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function answer(outcome: WriteOutcome, body: Record<string, unknown>): Response {
|
|
175
|
+
return outcome.ok
|
|
176
|
+
? json({ ...body, changed: outcome.changed })
|
|
177
|
+
: json({ error: outcome.error }, outcome.status);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** Close or reopen one criterion — the same rule `complete-isc` reaches. */
|
|
181
|
+
async function iscWrite(request: Request): Promise<Response> {
|
|
182
|
+
const body = await readBody(request);
|
|
183
|
+
if (!body) return json({ error: "expected a JSON body" }, 400);
|
|
184
|
+
const { project, id, status } = body;
|
|
185
|
+
if (typeof project !== "string" || !project) {
|
|
186
|
+
return json({ error: "project is required" }, 400);
|
|
187
|
+
}
|
|
188
|
+
if (typeof id !== "number" || !Number.isInteger(id) || id < 1) {
|
|
189
|
+
return json({ error: "id must be a positive integer" }, 400);
|
|
190
|
+
}
|
|
191
|
+
if (status !== "open" && status !== "done") {
|
|
192
|
+
return json({ error: "status must be open or done" }, 400);
|
|
193
|
+
}
|
|
194
|
+
return answer(setIscStatus(project, id, status === "done"), { project, id, status });
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
async function placementWrite(request: Request): Promise<Response> {
|
|
198
|
+
const body = await readBody(request);
|
|
199
|
+
if (!body) return json({ error: "expected a JSON body" }, 400);
|
|
200
|
+
const { project, quadrant } = body;
|
|
201
|
+
if (typeof project !== "string" || !project) {
|
|
202
|
+
return json({ error: "project is required" }, 400);
|
|
203
|
+
}
|
|
204
|
+
if (quadrant !== null && !isQuadrant(quadrant)) {
|
|
205
|
+
return json(
|
|
206
|
+
{ error: `quadrant must be null or one of ${QUADRANTS.join(", ")}` },
|
|
207
|
+
400
|
|
208
|
+
);
|
|
209
|
+
}
|
|
210
|
+
return answer(setPlacement(project, quadrant), { project, quadrant });
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
async function settingsWrite(request: Request): Promise<Response> {
|
|
214
|
+
const body = await readBody(request);
|
|
215
|
+
if (!body) return json({ error: "expected a JSON body" }, 400);
|
|
216
|
+
const update: Partial<InstallSettings> = {};
|
|
217
|
+
for (const key of ["actor", "timezone"] as const) {
|
|
218
|
+
const value = body[key];
|
|
219
|
+
if (value === undefined) continue;
|
|
220
|
+
if (typeof value !== "string") return json({ error: `${key} must be a string` }, 400);
|
|
221
|
+
update[key] = value;
|
|
222
|
+
}
|
|
223
|
+
const outcome = writeInstallSettings(update);
|
|
224
|
+
return outcome.ok ? json(readInstallSettings()) : answer(outcome, {});
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
async function snoozeWrite(request: Request): Promise<Response> {
|
|
228
|
+
const body = await readBody(request);
|
|
229
|
+
if (!body) return json({ error: "expected a JSON body" }, 400);
|
|
230
|
+
const { project, id, days } = body;
|
|
231
|
+
if (typeof project !== "string" || !project) {
|
|
232
|
+
return json({ error: "project is required" }, 400);
|
|
233
|
+
}
|
|
234
|
+
if (typeof id !== "number" || !Number.isInteger(id) || id < 1) {
|
|
235
|
+
return json({ error: "id must be a positive integer" }, 400);
|
|
236
|
+
}
|
|
237
|
+
if (
|
|
238
|
+
typeof days !== "number" ||
|
|
239
|
+
!Number.isInteger(days) ||
|
|
240
|
+
days < 0 ||
|
|
241
|
+
days > MAX_SNOOZE_DAYS
|
|
242
|
+
) {
|
|
243
|
+
return json({ error: `days must be a whole number of 0..${MAX_SNOOZE_DAYS}` }, 400);
|
|
244
|
+
}
|
|
245
|
+
return json({ project, id, until: setSnooze(project, id, days) });
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
async function readWrite(request: Request): Promise<Response> {
|
|
249
|
+
const body = await readBody(request);
|
|
250
|
+
if (!body) return json({ error: "expected a JSON body" }, 400);
|
|
251
|
+
const ids = body.all === true ? attention().items.map((i) => i.id) : body.ids;
|
|
252
|
+
if (!Array.isArray(ids) || ids.some((id) => typeof id !== "string")) {
|
|
253
|
+
return json({ error: "ids must be an array of strings, or all must be true" }, 400);
|
|
254
|
+
}
|
|
255
|
+
return json({ marked: markRead(ids as string[]) });
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
async function prefsWrite(request: Request): Promise<Response> {
|
|
259
|
+
const body = await readBody(request);
|
|
260
|
+
if (!body) return json({ error: "expected a JSON body" }, 400);
|
|
261
|
+
const failure = validatePrefs(body as PrefUpdate);
|
|
262
|
+
return failure ? json({ error: failure }, 400) : json(writePrefs(body as PrefUpdate));
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
const WRITES: Record<string, (request: Request) => Promise<Response>> = {
|
|
266
|
+
"/api/serves": overrideServes,
|
|
267
|
+
"/api/isc": iscWrite,
|
|
268
|
+
"/api/placement": placementWrite,
|
|
269
|
+
"/api/settings": settingsWrite,
|
|
270
|
+
"/api/snooze": snoozeWrite,
|
|
271
|
+
"/api/attention/read": readWrite,
|
|
272
|
+
"/api/prefs": prefsWrite,
|
|
273
|
+
};
|
|
274
|
+
|
|
102
275
|
export function startControlRoom(port: number = DEFAULT_PORT) {
|
|
103
276
|
const startedAt = new Date().toISOString();
|
|
104
277
|
return Bun.serve({
|
|
105
278
|
hostname: LOOPBACK,
|
|
106
279
|
port,
|
|
107
280
|
development: false,
|
|
108
|
-
routes:
|
|
281
|
+
routes: Object.fromEntries(PAGE_ROUTES.map((path) => [path, () => indexHtml()])),
|
|
109
282
|
fetch(request, server) {
|
|
110
283
|
const url = new URL(request.url);
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
}
|
|
284
|
+
const write = WRITES[url.pathname];
|
|
285
|
+
if (request.method === "POST" && write) return write(request);
|
|
114
286
|
if (request.method !== "GET") return json({ error: "read only" }, 405);
|
|
287
|
+
if (!url.pathname.startsWith("/api/")) {
|
|
288
|
+
return staticAsset(url.pathname) ?? json({ error: "not found" }, 404);
|
|
289
|
+
}
|
|
115
290
|
switch (url.pathname) {
|
|
116
291
|
case "/api/agenda":
|
|
117
292
|
return json(agenda());
|
|
@@ -127,8 +302,18 @@ export function startControlRoom(port: number = DEFAULT_PORT) {
|
|
|
127
302
|
return withFilter(url, agentsAtWork);
|
|
128
303
|
case "/api/ledger":
|
|
129
304
|
return withFilter(url, ledgerView);
|
|
305
|
+
case "/api/project":
|
|
306
|
+
return oneProject(url.searchParams.get("slug"));
|
|
307
|
+
case "/api/summary":
|
|
308
|
+
return summaryFor(url.searchParams.get("days"));
|
|
130
309
|
case "/api/projects":
|
|
131
310
|
return projects();
|
|
311
|
+
case "/api/settings":
|
|
312
|
+
return json(readInstallSettings());
|
|
313
|
+
case "/api/prefs":
|
|
314
|
+
return json(readPrefs());
|
|
315
|
+
case "/api/attention":
|
|
316
|
+
return json(attention());
|
|
132
317
|
case "/api/status":
|
|
133
318
|
return status(server.port ?? port, startedAt);
|
|
134
319
|
default:
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Snoozed criteria — an ISC you have seen and do not want ranked this week.
|
|
3
|
+
*
|
|
4
|
+
* Kept out of the ISA on purpose: the record says what the project is judged
|
|
5
|
+
* against, and "not this week" is a fact about your attention rather than about
|
|
6
|
+
* the project. It expires by date, so nothing has to unsnooze it.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
10
|
+
import { resolve } from "node:path";
|
|
11
|
+
import { ensureDir, paths } from "../../hooks/lib/paths";
|
|
12
|
+
|
|
13
|
+
const DAY_MS = 86_400_000;
|
|
14
|
+
export const MAX_SNOOZE_DAYS = 90;
|
|
15
|
+
|
|
16
|
+
/** project#isc → the day it wakes up. */
|
|
17
|
+
export type Snoozes = Record<string, string>;
|
|
18
|
+
|
|
19
|
+
function snoozeKey(project: string, id: number): string {
|
|
20
|
+
return `${project}#${id}`;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function snoozeFile(): string {
|
|
24
|
+
return resolve(ensureDir(paths.state()), "snoozed.json");
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function readSnoozes(now: Date = new Date()): Snoozes {
|
|
28
|
+
const file = snoozeFile();
|
|
29
|
+
if (!existsSync(file)) return {};
|
|
30
|
+
let stored: Snoozes;
|
|
31
|
+
try {
|
|
32
|
+
stored = JSON.parse(readFileSync(file, "utf-8")) as Snoozes;
|
|
33
|
+
} catch {
|
|
34
|
+
return {};
|
|
35
|
+
}
|
|
36
|
+
const today = now.toISOString().slice(0, 10);
|
|
37
|
+
return Object.fromEntries(
|
|
38
|
+
Object.entries(stored).filter(
|
|
39
|
+
([, until]) => typeof until === "string" && until > today
|
|
40
|
+
)
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function snoozedUntil(
|
|
45
|
+
project: string,
|
|
46
|
+
id: number,
|
|
47
|
+
snoozes: Snoozes
|
|
48
|
+
): string | null {
|
|
49
|
+
return snoozes[snoozeKey(project, id)] ?? null;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Zero days wakes it now, which is how the page un-snoozes. */
|
|
53
|
+
export function setSnooze(
|
|
54
|
+
project: string,
|
|
55
|
+
id: number,
|
|
56
|
+
days: number,
|
|
57
|
+
now: Date = new Date()
|
|
58
|
+
): string | null {
|
|
59
|
+
const live = readSnoozes(now);
|
|
60
|
+
const key = snoozeKey(project, id);
|
|
61
|
+
if (days <= 0) delete live[key];
|
|
62
|
+
else live[key] = new Date(now.getTime() + days * DAY_MS).toISOString().slice(0, 10);
|
|
63
|
+
writeFileSync(snoozeFile(), `${JSON.stringify(live, null, 2)}\n`, "utf-8");
|
|
64
|
+
return live[key] ?? null;
|
|
65
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The built page, off disk. Vite compiles src/tools/control-room/ui into
|
|
3
|
+
* ui/dist; this serves it, and says so plainly when the build has not been run
|
|
4
|
+
* rather than answering a blank page.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { spawnSync } from "node:child_process";
|
|
8
|
+
import { existsSync } from "node:fs";
|
|
9
|
+
import { isAbsolute, relative, resolve } from "node:path";
|
|
10
|
+
|
|
11
|
+
const UI_DIST = resolve(import.meta.dir, "ui", "dist");
|
|
12
|
+
|
|
13
|
+
export const BUILD_COMMAND = "bun run build:ui";
|
|
14
|
+
|
|
15
|
+
export function isBuilt(): boolean {
|
|
16
|
+
return existsSync(resolve(UI_DIST, "index.html"));
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* A clone has vite; a published install has the prepacked dist and never needs
|
|
21
|
+
* it. Either way the page is there by the time the server answers.
|
|
22
|
+
*/
|
|
23
|
+
export function buildPage(): boolean {
|
|
24
|
+
const repoRoot = resolve(import.meta.dir, "..", "..", "..");
|
|
25
|
+
// node_modules/.bin/vite is a shell shim that Windows cannot spawn directly;
|
|
26
|
+
// the package's own JS entry runs the same build under any runtime.
|
|
27
|
+
const vite = resolve(repoRoot, "node_modules", "vite", "bin", "vite.js");
|
|
28
|
+
if (!existsSync(vite)) return false;
|
|
29
|
+
const built = spawnSync(process.execPath, [vite, "build"], {
|
|
30
|
+
cwd: resolve(import.meta.dir, "ui"),
|
|
31
|
+
encoding: "utf-8",
|
|
32
|
+
});
|
|
33
|
+
return built.status === 0 && isBuilt();
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function unbuiltPage(): Response {
|
|
37
|
+
return new Response(
|
|
38
|
+
`<!doctype html><meta charset="utf-8"><title>PAL control room</title>` +
|
|
39
|
+
`<body style="font:14px system-ui;padding:3rem;max-width:34rem">` +
|
|
40
|
+
`<h1>The page has not been built</h1>` +
|
|
41
|
+
`<p>The control room's assets are a build artifact rather than source. Run:</p>` +
|
|
42
|
+
`<pre style="background:#eee;padding:1rem">${BUILD_COMMAND}</pre></body>`,
|
|
43
|
+
{ status: 503, headers: { "content-type": "text/html; charset=utf-8" } }
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function indexHtml(): Response {
|
|
48
|
+
if (!isBuilt()) return unbuiltPage();
|
|
49
|
+
return new Response(Bun.file(resolve(UI_DIST, "index.html")), {
|
|
50
|
+
headers: { "content-type": "text/html; charset=utf-8" },
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Only what the build emitted, resolved inside dist — a request that climbs out
|
|
56
|
+
* of it with `..` is a miss, not a file read.
|
|
57
|
+
*/
|
|
58
|
+
export function staticAsset(pathname: string): Response | null {
|
|
59
|
+
const target = resolve(UI_DIST, `.${pathname}`);
|
|
60
|
+
if (!isInsideDist(target)) return null;
|
|
61
|
+
return existsSync(target) ? new Response(Bun.file(target)) : null;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Compared as paths, not strings — a separator differs by platform. */
|
|
65
|
+
function isInsideDist(target: string): boolean {
|
|
66
|
+
const rel = relative(UI_DIST, target);
|
|
67
|
+
return rel !== "" && !rel.startsWith("..") && !isAbsolute(rel);
|
|
68
|
+
}
|
|
@@ -1,67 +1,213 @@
|
|
|
1
|
-
import { StrictMode
|
|
1
|
+
import { StrictMode } from "react";
|
|
2
2
|
import { createRoot } from "react-dom/client";
|
|
3
|
+
import {
|
|
4
|
+
BrowserRouter,
|
|
5
|
+
Navigate,
|
|
6
|
+
NavLink,
|
|
7
|
+
Route,
|
|
8
|
+
Routes,
|
|
9
|
+
useParams,
|
|
10
|
+
useSearchParams,
|
|
11
|
+
} from "react-router";
|
|
3
12
|
import type { ServerStatus } from "../server";
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
8
|
-
import {
|
|
9
|
-
import {
|
|
10
|
-
import {
|
|
11
|
-
|
|
13
|
+
import { AttentionBell } from "./attention-bell";
|
|
14
|
+
import { Screen, Scroller, Seg } from "./frame";
|
|
15
|
+
import { useLoaded } from "./lib/api";
|
|
16
|
+
import { cn } from "./lib/cn";
|
|
17
|
+
import { LOG_KEYS, Log, type LogFilter } from "./screens/log";
|
|
18
|
+
import { ProjectDetail } from "./screens/project-detail";
|
|
19
|
+
import {
|
|
20
|
+
isStatusFilter,
|
|
21
|
+
Projects,
|
|
22
|
+
STATUS_FILTERS,
|
|
23
|
+
type StatusFilter,
|
|
24
|
+
} from "./screens/projects";
|
|
25
|
+
import { Settings } from "./screens/settings";
|
|
26
|
+
import { isLayout, LAYOUTS, type Layout, Today } from "./screens/today";
|
|
12
27
|
|
|
13
|
-
|
|
28
|
+
const LAYOUT_KEY = "pal.control-room.layout";
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* A blueprint's registration marks are drawn 6px outside its border, so a scroll
|
|
32
|
+
* box whose children are blueprints would count them as overflow. The negative
|
|
33
|
+
* margin borrows that room back from the page gutter, leaving content aligned.
|
|
34
|
+
*/
|
|
35
|
+
const MARK_ROOM = "-m-2 p-2";
|
|
36
|
+
|
|
37
|
+
function rememberedLayout(): Layout {
|
|
38
|
+
try {
|
|
39
|
+
const stored = localStorage.getItem(LAYOUT_KEY);
|
|
40
|
+
return isLayout(stored) ? stored : "matrix";
|
|
41
|
+
} catch {
|
|
42
|
+
return "matrix";
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function remember(layout: Layout): void {
|
|
47
|
+
try {
|
|
48
|
+
localStorage.setItem(LAYOUT_KEY, layout);
|
|
49
|
+
} catch {}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function TodayRoute() {
|
|
53
|
+
const [params, setParams] = useSearchParams();
|
|
54
|
+
const asked = params.get("layout");
|
|
55
|
+
const layout = isLayout(asked) ? asked : rememberedLayout();
|
|
14
56
|
return (
|
|
15
|
-
<div className="
|
|
16
|
-
<
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
57
|
+
<div className="flex min-h-0 flex-1 flex-col">
|
|
58
|
+
<div className="mb-4 flex justify-end">
|
|
59
|
+
<Seg
|
|
60
|
+
label="layout"
|
|
61
|
+
options={LAYOUTS}
|
|
62
|
+
value={layout}
|
|
63
|
+
onPick={(next) => {
|
|
64
|
+
remember(next);
|
|
65
|
+
setParams({ layout: next });
|
|
66
|
+
}}
|
|
67
|
+
/>
|
|
68
|
+
</div>
|
|
69
|
+
<Scroller className={MARK_ROOM}>
|
|
70
|
+
<Today layout={layout} />
|
|
71
|
+
</Scroller>
|
|
72
|
+
</div>
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function ProjectsRoute() {
|
|
77
|
+
const [params, setParams] = useSearchParams();
|
|
78
|
+
const asked = params.get("status");
|
|
79
|
+
const filter: StatusFilter = isStatusFilter(asked) ? asked : "ranked";
|
|
80
|
+
return (
|
|
81
|
+
<Screen
|
|
82
|
+
title="Projects"
|
|
83
|
+
aside={
|
|
84
|
+
<Seg
|
|
85
|
+
label="status"
|
|
86
|
+
options={STATUS_FILTERS}
|
|
87
|
+
value={filter}
|
|
88
|
+
onPick={(next) => setParams({ status: next })}
|
|
89
|
+
/>
|
|
90
|
+
}
|
|
91
|
+
>
|
|
92
|
+
<Projects filter={filter} />
|
|
93
|
+
</Screen>
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function ProjectDetailRoute() {
|
|
98
|
+
const { slug } = useParams();
|
|
99
|
+
return (
|
|
100
|
+
<Scroller className={MARK_ROOM}>
|
|
101
|
+
<ProjectDetail slug={slug ?? ""} />
|
|
102
|
+
</Scroller>
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function LogRoute() {
|
|
107
|
+
const [params, setParams] = useSearchParams();
|
|
108
|
+
const filter = Object.fromEntries(
|
|
109
|
+
LOG_KEYS.map((key) => [key, params.get(key) ?? ""])
|
|
110
|
+
) as LogFilter;
|
|
111
|
+
const update = (next: Partial<LogFilter>) => {
|
|
112
|
+
const merged = { ...filter, ...next };
|
|
113
|
+
setParams(Object.fromEntries(Object.entries(merged).filter(([, v]) => v)));
|
|
114
|
+
};
|
|
115
|
+
return (
|
|
116
|
+
<Screen
|
|
117
|
+
title="Action log"
|
|
118
|
+
aside={
|
|
119
|
+
<span className="text-[12px] text-neutral-700">
|
|
120
|
+
every tool call this install recorded
|
|
25
121
|
</span>
|
|
122
|
+
}
|
|
123
|
+
>
|
|
124
|
+
<Log filter={filter} onFilter={update} />
|
|
125
|
+
</Screen>
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function SettingsRoute() {
|
|
130
|
+
return (
|
|
131
|
+
<Screen title="Settings">
|
|
132
|
+
<Scroller className={MARK_ROOM}>
|
|
133
|
+
<Settings />
|
|
134
|
+
</Scroller>
|
|
135
|
+
</Screen>
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const TABS = [
|
|
140
|
+
{ to: "/", label: "Today", end: true },
|
|
141
|
+
{ to: "/projects", label: "Projects", end: false },
|
|
142
|
+
{ to: "/log", label: "Action log", end: false },
|
|
143
|
+
{ to: "/settings", label: "Settings", end: false },
|
|
144
|
+
] as const;
|
|
145
|
+
|
|
146
|
+
function Header({ status }: { status: ServerStatus | null }) {
|
|
147
|
+
return (
|
|
148
|
+
<header className="flex flex-wrap items-center gap-5 border-b border-divider bg-bg px-7 py-2.5">
|
|
149
|
+
<div className="flex min-w-[120px] items-baseline gap-2">
|
|
150
|
+
<span className="font-heading text-[22px] font-semibold tracking-wide">PAL</span>
|
|
151
|
+
<span className="eyebrow">control</span>
|
|
152
|
+
</div>
|
|
153
|
+
<nav className="-mb-[11px] flex gap-0.5">
|
|
154
|
+
{TABS.map((tab) => (
|
|
155
|
+
<NavLink
|
|
156
|
+
key={tab.to}
|
|
157
|
+
to={tab.to}
|
|
158
|
+
end={tab.end}
|
|
159
|
+
className={({ isActive }) =>
|
|
160
|
+
cn(
|
|
161
|
+
"font-heading border-b-2 px-3 py-2 text-[15px] font-semibold whitespace-nowrap",
|
|
162
|
+
isActive
|
|
163
|
+
? "border-accent text-ink"
|
|
164
|
+
: "border-transparent text-neutral-600 hover:text-accent"
|
|
165
|
+
)
|
|
166
|
+
}
|
|
167
|
+
>
|
|
168
|
+
{tab.label}
|
|
169
|
+
</NavLink>
|
|
170
|
+
))}
|
|
171
|
+
</nav>
|
|
172
|
+
<div className="ml-auto flex items-center gap-3 text-[12px] text-neutral-700">
|
|
173
|
+
<span className="inline-block size-1.5 bg-accent" />
|
|
174
|
+
<b className="font-medium text-ink">{status?.machine ?? "…"}</b>
|
|
26
175
|
<span>
|
|
27
|
-
|
|
176
|
+
· {status ? `${status.ledgerFiles} ledger file(s)` : "…"} · port{" "}
|
|
177
|
+
{status?.port ?? "…"}
|
|
28
178
|
</span>
|
|
179
|
+
<AttentionBell />
|
|
29
180
|
</div>
|
|
30
|
-
</
|
|
181
|
+
</header>
|
|
31
182
|
);
|
|
32
183
|
}
|
|
33
184
|
|
|
34
185
|
function App() {
|
|
35
|
-
const
|
|
36
|
-
const [error, setError] = useState<string | null>(null);
|
|
37
|
-
useEffect(() => {
|
|
38
|
-
getJson<ServerStatus>("/api/status")
|
|
39
|
-
.then(setStatus)
|
|
40
|
-
.catch((e: Error) => setError(e.message));
|
|
41
|
-
}, []);
|
|
186
|
+
const status = useLoaded<ServerStatus>("/api/status");
|
|
42
187
|
return (
|
|
43
|
-
<
|
|
44
|
-
<
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
188
|
+
<BrowserRouter>
|
|
189
|
+
<div className="flex h-screen flex-col overflow-hidden">
|
|
190
|
+
<Header status={status.state === "ready" ? status.data : null} />
|
|
191
|
+
<main className="mx-auto flex w-full max-w-[1480px] min-h-0 flex-1 flex-col px-7 pt-6 pb-6">
|
|
192
|
+
<Routes>
|
|
193
|
+
<Route path="/" element={<TodayRoute />} />
|
|
194
|
+
<Route path="/projects" element={<ProjectsRoute />} />
|
|
195
|
+
<Route path="/projects/:slug" element={<ProjectDetailRoute />} />
|
|
196
|
+
<Route path="/log" element={<LogRoute />} />
|
|
197
|
+
<Route path="/settings" element={<SettingsRoute />} />
|
|
198
|
+
<Route path="*" element={<Navigate to="/" replace />} />
|
|
199
|
+
</Routes>
|
|
200
|
+
</main>
|
|
201
|
+
<footer className="flex flex-wrap justify-between gap-4 border-t border-divider px-7 py-3 text-[11px] text-neutral-600">
|
|
202
|
+
<span>
|
|
203
|
+
loopback only · every number from ~/.pal · no model runs on this page
|
|
204
|
+
</span>
|
|
205
|
+
<span>
|
|
206
|
+
{status.state === "ready" ? `up since ${status.data.startedAt}` : ""}
|
|
207
|
+
</span>
|
|
208
|
+
</footer>
|
|
50
209
|
</div>
|
|
51
|
-
|
|
52
|
-
<summary>When something breaks — projects, agents, signal, ledger</summary>
|
|
53
|
-
<div className="grid">
|
|
54
|
-
<Board />
|
|
55
|
-
<Signal />
|
|
56
|
-
<Agents />
|
|
57
|
-
<Ledger />
|
|
58
|
-
</div>
|
|
59
|
-
</details>
|
|
60
|
-
<div className="foot">
|
|
61
|
-
<span>loopback only · every number from ~/.pal · no model runs on this page</span>
|
|
62
|
-
<span>{status ? `up since ${status.startedAt}` : ""}</span>
|
|
63
|
-
</div>
|
|
64
|
-
</div>
|
|
210
|
+
</BrowserRouter>
|
|
65
211
|
);
|
|
66
212
|
}
|
|
67
213
|
|