@unifedev/thread-pages 0.3.2 → 1.1.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.
Files changed (95) hide show
  1. package/README.md +77 -129
  2. package/dist/server.js +11779 -11801
  3. package/dist/server.meta.json +2 -2
  4. package/docs/B1-OWN-FILES.md +133 -0
  5. package/docs/FOR-PAGE-AUTHORS-1.1.md +167 -0
  6. package/docs/UPGRADING.md +53 -0
  7. package/package.json +26 -18
  8. package/server.ts +3 -2175
  9. package/src/agent/cli.ts +193 -0
  10. package/src/agent/guide.ts +450 -0
  11. package/src/agent/instruction.ts +59 -0
  12. package/src/agent/seed/seed.ts +73 -0
  13. package/{theme.ts → src/agent/seed/theme-css.ts} +9 -11
  14. package/src/agent/starter-hub.ts +217 -0
  15. package/src/bb/activity.ts +59 -0
  16. package/src/bb/bb-host.ts +280 -0
  17. package/src/bb/public-origin.ts +45 -0
  18. package/src/config/settings.ts +82 -0
  19. package/src/domain/capabilities/contract.ts +48 -0
  20. package/src/domain/capabilities/index.ts +10 -0
  21. package/src/domain/capabilities/protocol.ts +113 -0
  22. package/src/domain/capabilities/registry.ts +48 -0
  23. package/src/domain/capabilities/renamed.ts +34 -0
  24. package/src/domain/capabilities/schema.ts +198 -0
  25. package/src/domain/capabilities/specs.ts +479 -0
  26. package/src/domain/eligibility.ts +43 -0
  27. package/src/domain/errors.ts +116 -0
  28. package/src/domain/html/document.ts +109 -0
  29. package/src/domain/html/escape.ts +16 -0
  30. package/src/domain/ids.ts +37 -0
  31. package/src/domain/json/canonical.ts +19 -0
  32. package/src/domain/json/strict-json.ts +139 -0
  33. package/src/domain/limits.ts +98 -0
  34. package/src/domain/rate-limit.ts +64 -0
  35. package/src/domain/revision.ts +27 -0
  36. package/src/domain/submissions/idempotency.ts +59 -0
  37. package/src/domain/submissions/message.ts +42 -0
  38. package/src/domain/submissions/parse.ts +105 -0
  39. package/src/domain/tokens/action-token.ts +52 -0
  40. package/src/domain/tokens/confirmation.ts +99 -0
  41. package/src/domain/tokens/mac.ts +50 -0
  42. package/src/generated/kernel-runtime.ts +3 -0
  43. package/src/generated/shell-runtime.ts +3 -0
  44. package/src/host/contract.ts +65 -0
  45. package/src/host/types.ts +89 -0
  46. package/src/pages/inline.ts +277 -0
  47. package/src/pages/layout.ts +65 -0
  48. package/src/pages/page-store.ts +170 -0
  49. package/src/pages/site.ts +36 -0
  50. package/src/plugin.ts +81 -0
  51. package/src/runtime/kernel/anchors.ts +45 -0
  52. package/src/runtime/kernel/api.ts +15 -0
  53. package/src/runtime/kernel/bridge-client.ts +148 -0
  54. package/src/runtime/kernel/dirty.ts +51 -0
  55. package/src/runtime/kernel/forms.ts +114 -0
  56. package/src/runtime/kernel/install.ts +156 -0
  57. package/src/runtime/kernel/labels.ts +98 -0
  58. package/src/runtime/kernel/main.ts +6 -0
  59. package/src/runtime/kernel/readonly.ts +75 -0
  60. package/src/runtime/shared/protocol.ts +125 -0
  61. package/src/runtime/shell/confirm.ts +70 -0
  62. package/src/runtime/shell/install.ts +79 -0
  63. package/src/runtime/shell/main.ts +12 -0
  64. package/src/runtime/shell/navigate.ts +64 -0
  65. package/src/runtime/shell/poll.ts +125 -0
  66. package/src/runtime/shell/relay.ts +185 -0
  67. package/src/serving/action-request.ts +32 -0
  68. package/src/serving/bridge/dispatcher.ts +112 -0
  69. package/src/serving/bridge/handler.ts +37 -0
  70. package/src/serving/bridge/handlers/index.ts +26 -0
  71. package/src/serving/bridge/handlers/navigation.ts +43 -0
  72. package/src/serving/bridge/handlers/reads.ts +186 -0
  73. package/src/serving/bridge/handlers/writes.ts +175 -0
  74. package/src/serving/bridge/selection-store.ts +58 -0
  75. package/src/serving/bridge-route.ts +23 -0
  76. package/src/serving/context.ts +34 -0
  77. package/src/serving/document-route.ts +37 -0
  78. package/src/serving/home-route.ts +23 -0
  79. package/src/serving/responses.ts +81 -0
  80. package/src/serving/routes.ts +26 -0
  81. package/src/serving/session-access.ts +22 -0
  82. package/src/serving/shell-html.ts +77 -0
  83. package/src/serving/shell-route.ts +51 -0
  84. package/src/serving/signing-key.ts +25 -0
  85. package/src/serving/submit-route.ts +47 -0
  86. package/src/serving/upload-route.ts +46 -0
  87. package/tsconfig.json +10 -6
  88. package/ARCHITECTURE.md +0 -230
  89. package/PLUGIN_OVERVIEW.md +0 -83
  90. package/authoring.ts +0 -368
  91. package/bridge.ts +0 -1721
  92. package/docs/MODEL.md +0 -211
  93. package/docs/ROADMAP.md +0 -96
  94. package/home.ts +0 -419
  95. package/page.ts +0 -782
@@ -0,0 +1,32 @@
1
+ import type { Context } from "hono";
2
+ import { PageError, PUBLIC_MESSAGES } from "../domain/errors.ts";
3
+ import { verifyActionToken, type ActionToken } from "../domain/tokens/action-token.ts";
4
+ import type { ServingContext } from "./context.ts";
5
+
6
+ /**
7
+ * The steps every effectful route shares: a bounded JSON body, a valid action
8
+ * token, and a slot in the page's rate budget. spec R2.6, R2.38
9
+ */
10
+ export async function readJsonBody(context: Context, maxBytes: number): Promise<unknown> {
11
+ const declared = Number(context.req.header("content-length") ?? "0");
12
+ if (Number.isFinite(declared) && declared > maxBytes) throw new PageError("request_too_large", "Request body is too large");
13
+ const raw = await context.req.text();
14
+ if (Buffer.byteLength(raw, "utf8") > maxBytes) throw new PageError("request_too_large", "Request body is too large");
15
+ try {
16
+ return JSON.parse(raw) as unknown;
17
+ } catch {
18
+ throw new PageError("invalid_json", "Request body is not valid JSON");
19
+ }
20
+ }
21
+
22
+ export function requireActionToken(serving: ServingContext, token: unknown): ActionToken {
23
+ const verified = typeof token === "string" ? verifyActionToken(token, serving.signingKey, serving.now()) : null;
24
+ if (!verified) throw new PageError("confirmation_invalid", PUBLIC_MESSAGES.tokenInvalid, { status: 401 });
25
+ return verified;
26
+ }
27
+
28
+ export function acquireRate(serving: ServingContext, session: string): () => void {
29
+ const release = serving.rate.acquire(session, serving.now());
30
+ if (!release) throw new PageError("rate_limited", PUBLIC_MESSAGES.rateLimited);
31
+ return release;
32
+ }
@@ -0,0 +1,112 @@
1
+ import { completeInvocation, decodeBridgeRequest, failure, failureFromError, resolveInvocation, type BridgeTransport } from "../../domain/capabilities/protocol.ts";
2
+ import { PageError, PUBLIC_MESSAGES, errorText, isBridgeErrorCode } from "../../domain/errors.ts";
3
+ import type { JsonValue } from "../../domain/json/strict-json.ts";
4
+ import { LIMITS } from "../../domain/limits.ts";
5
+ import { challengeMatches, mintChallenge, openChallenge } from "../../domain/tokens/confirmation.ts";
6
+ import { acquireRate, requireActionToken } from "../action-request.ts";
7
+ import type { ServingContext } from "../context.ts";
8
+ import { eligibleSession } from "../session-access.ts";
9
+ import type { CapabilityHandler, HandlerContext } from "./handler.ts";
10
+
11
+ /**
12
+ * The one path every capability call takes. spec 03 §Confirmed effects, 05
13
+ *
14
+ * envelope → action token → rate budget → resolve (stale, unknown, params)
15
+ * → cheap refusals → confirmation (challenge out, or verify one in)
16
+ * → session still eligible, page still current → handler → projection
17
+ */
18
+ export interface DispatchResult {
19
+ readonly status: number;
20
+ readonly body: BridgeTransport;
21
+ }
22
+
23
+ interface Envelope {
24
+ readonly actionToken: string;
25
+ readonly request: unknown;
26
+ readonly confirmation: string | null;
27
+ }
28
+
29
+ export function parseEnvelope(value: unknown): Envelope {
30
+ if (!value || typeof value !== "object" || Array.isArray(value)) throw new PageError("invalid_request", "Invalid bridge envelope");
31
+ const input = value as Record<string, unknown>;
32
+ const keys = Object.keys(input);
33
+ if (!keys.includes("actionToken") || !keys.includes("request") || keys.some((key) => !["actionToken", "request", "confirmation"].includes(key))) {
34
+ throw new PageError("invalid_request", "Invalid bridge envelope");
35
+ }
36
+ if (typeof input.actionToken !== "string" || input.actionToken.length > LIMITS.tokenChars) throw new PageError("invalid_request", "Invalid bridge envelope");
37
+ const confirmation = input.confirmation;
38
+ if (confirmation !== undefined && confirmation !== null && (typeof confirmation !== "string" || confirmation.length > LIMITS.tokenChars)) {
39
+ throw new PageError("invalid_request", "Invalid bridge envelope");
40
+ }
41
+ return { actionToken: input.actionToken, request: input.request, confirmation: typeof confirmation === "string" ? confirmation : null };
42
+ }
43
+
44
+ export function createDispatcher(serving: ServingContext, handlers: readonly CapabilityHandler[]) {
45
+ const byMethod = new Map(handlers.map((entry) => [entry.method, entry]));
46
+ for (const spec of serving.registry.list()) {
47
+ if (spec.implemented && !byMethod.has(spec.method)) throw new Error(`No handler for capability ${spec.method}`);
48
+ }
49
+
50
+ return async function dispatch(body: unknown): Promise<DispatchResult> {
51
+ let requestId: unknown;
52
+ let release: (() => void) | null = null;
53
+ try {
54
+ const envelope = parseEnvelope(body);
55
+ requestId = (envelope.request as { id?: unknown } | null)?.id;
56
+ const token = requireActionToken(serving, envelope.actionToken);
57
+ release = acquireRate(serving, token.session);
58
+ const request = decodeBridgeRequest(envelope.request);
59
+ requestId = request.id;
60
+ const invocation = resolveInvocation(request, serving.registry, token.revision);
61
+ const entry = byMethod.get(invocation.spec.method);
62
+ if (!entry) throw new PageError("unknown_method", `Unknown capability: ${invocation.spec.method}`);
63
+
64
+ const session = await eligibleSession(serving, token.session).catch((error: unknown) => {
65
+ throw PageError.is(error) && error.code === "ineligible" ? new PageError("conflict", "This session no longer accepts page actions") : error;
66
+ });
67
+ const page = await serving.pages.load(token.session);
68
+ if (page.revision !== token.revision) throw new PageError("stale_page", PUBLIC_MESSAGES.stalePage);
69
+ const context: HandlerContext = { serving, session, page, requestId: request.id };
70
+
71
+ await entry.refuse?.(invocation.params, context);
72
+
73
+ if (invocation.spec.confirmed) {
74
+ const binding = { session: token.session, revision: token.revision, requestId: request.id, method: request.method, params: invocation.params as JsonValue };
75
+ if (envelope.confirmation === null) {
76
+ const summary = (await entry.summarize?.(invocation.params, context)) ?? invocation.spec.description;
77
+ const { challenge, payload } = mintChallenge(binding, summary, serving.now(), serving.signingKey);
78
+ return { status: 401, body: { confirm: { requestId: request.id, summary: payload.summary, challenge } } };
79
+ }
80
+ const challenge = openChallenge(envelope.confirmation, serving.signingKey, serving.now());
81
+ if (!challenge || !challengeMatches(challenge, binding)) {
82
+ throw new PageError("confirmation_invalid", "The confirmation is expired or does not match this request");
83
+ }
84
+ }
85
+
86
+ if (page.stale && invocation.spec.effect !== "read" && invocation.spec.effect !== "navigation") {
87
+ throw new PageError("unavailable", PUBLIC_MESSAGES.staleCopy);
88
+ }
89
+
90
+ let outcome;
91
+ try {
92
+ outcome = await entry.execute(invocation.params, context);
93
+ } catch (error) {
94
+ if (PageError.is(error) && isBridgeErrorCode(error.code)) throw error;
95
+ serving.host.log.warn(`bridge ${request.method} for ${token.session}: ${errorText(error)}`);
96
+ throw new PageError("handler_error", PUBLIC_MESSAGES.handler, { cause: error });
97
+ }
98
+ const response = completeInvocation(invocation, outcome.result);
99
+ return { status: response.ok ? 200 : 500, body: outcome.navigate ? { response, navigate: outcome.navigate } : { response } };
100
+ } catch (error) {
101
+ if (PageError.is(error)) {
102
+ if (error.cause !== undefined) serving.host.log.warn(`bridge: ${error.code}: ${errorText(error.cause)}`);
103
+ const code = isBridgeErrorCode(error.code) ? error.code : error.code === "ineligible" || error.code === "no_page" ? "not_found" : "handler_error";
104
+ return { status: error.status, body: { response: failure(requestId, code, error.message) } };
105
+ }
106
+ serving.host.log.warn(`bridge: ${errorText(error)}`);
107
+ return { status: 500, body: { response: failureFromError(requestId, error) } };
108
+ } finally {
109
+ release?.();
110
+ }
111
+ };
112
+ }
@@ -0,0 +1,37 @@
1
+ import type { NavigationDirective } from "../../domain/capabilities/protocol.ts";
2
+ import type { SessionRecord } from "../../host/types.ts";
3
+ import type { LoadedPage } from "../../pages/page-store.ts";
4
+ import type { ServingContext } from "../context.ts";
5
+
6
+ /**
7
+ * A capability's server-side half. `refuse` runs before any confirmation so
8
+ * cheap refusals (own session, not found) never show a dialog; `summarize`
9
+ * words the confirmation from validated parameters; `execute` acts.
10
+ */
11
+ export interface HandlerContext {
12
+ readonly serving: ServingContext;
13
+ readonly session: SessionRecord;
14
+ readonly page: LoadedPage;
15
+ readonly requestId: string;
16
+ }
17
+
18
+ export interface HandlerOutcome<R> {
19
+ readonly result: R;
20
+ readonly navigate?: NavigationDirective;
21
+ }
22
+
23
+ export interface CapabilityHandler<P = unknown, R = unknown> {
24
+ readonly method: string;
25
+ refuse?(params: P, context: HandlerContext): Promise<void>;
26
+ summarize?(params: P, context: HandlerContext): Promise<string>;
27
+ execute(params: P, context: HandlerContext): Promise<HandlerOutcome<R>>;
28
+ }
29
+
30
+ export function handler<P, R>(definition: CapabilityHandler<P, R>): CapabilityHandler<P, R> {
31
+ return definition;
32
+ }
33
+
34
+ export function excerpt(text: string, max = 80): string {
35
+ const line = text.replace(/\s+/g, " ").trim();
36
+ return line.length <= max ? line : `${line.slice(0, max - 1)}…`;
37
+ }
@@ -0,0 +1,26 @@
1
+ import type { CapabilityHandler } from "../handler.ts";
2
+ import { navigationOpenExternal, pagesOpen, sessionsOpenHost } from "./navigation.ts";
3
+ import { contextGet, projectsList, providersList, sessionActivity, sessionsSnapshot, storageGet, storageSet } from "./reads.ts";
4
+ import { projectsBrowse, projectsCreate, sessionReply, sessionsArchive, sessionsMarkRead, sessionsSend, sessionsStart, sessionsStop } from "./writes.ts";
5
+
6
+ /** Every implemented capability's handler. The dispatcher checks this list against the registry at load. */
7
+ export const ALL_HANDLERS: readonly CapabilityHandler[] = [
8
+ contextGet,
9
+ sessionActivity,
10
+ sessionsSnapshot,
11
+ projectsList,
12
+ providersList,
13
+ storageGet,
14
+ storageSet,
15
+ sessionReply,
16
+ sessionsSend,
17
+ sessionsStart,
18
+ sessionsStop,
19
+ sessionsArchive,
20
+ sessionsMarkRead,
21
+ projectsBrowse,
22
+ projectsCreate,
23
+ pagesOpen,
24
+ sessionsOpenHost,
25
+ navigationOpenExternal,
26
+ ] as CapabilityHandler[];
@@ -0,0 +1,43 @@
1
+ import type { OpenExternalParams } from "../../../domain/capabilities/specs.ts";
2
+ import { ineligibleReason } from "../../../domain/eligibility.ts";
3
+ import { PageError } from "../../../domain/errors.ts";
4
+ import { pageUrl } from "../../context.ts";
5
+ import { excerpt, handler } from "../handler.ts";
6
+
7
+ /**
8
+ * Navigation: the host validates the destination and the trusted shell
9
+ * performs it in place (pages, host application) or, after confirmation,
10
+ * to an external origin. spec R5.29–R5.34
11
+ */
12
+ export const pagesOpen = handler<{ sessionId: string }, unknown>({
13
+ method: "pages.open",
14
+ async refuse(params, { serving }) {
15
+ const target = await serving.host.sessions.get(params.sessionId);
16
+ if (!target || ineligibleReason(target)) throw new PageError("not_found", "That session has no page");
17
+ },
18
+ async execute(params, { serving }) {
19
+ return { result: { opened: true }, navigate: { kind: "page", url: pageUrl(serving.routeBase, params.sessionId) } };
20
+ },
21
+ });
22
+
23
+ export const sessionsOpenHost = handler<{ sessionId: string }, unknown>({
24
+ method: "sessions.openHost",
25
+ async refuse(params, { serving }) {
26
+ const target = await serving.host.sessions.get(params.sessionId);
27
+ if (!target || target.deleted) throw new PageError("not_found", "That session is not available");
28
+ },
29
+ async execute(params, { serving }) {
30
+ return { result: { opened: true }, navigate: { kind: "host", url: serving.hostSessionUrl(params.sessionId) } };
31
+ },
32
+ });
33
+
34
+ export const navigationOpenExternal = handler<OpenExternalParams, unknown>({
35
+ method: "navigation.openExternal",
36
+ async summarize(params) {
37
+ const origin = new URL(params.url).origin;
38
+ return params.label ? `Leave this page and open “${excerpt(params.label, 60)}” at ${origin}` : `Leave this page and open ${origin}`;
39
+ },
40
+ async execute(params) {
41
+ return { result: { opened: true }, navigate: { kind: "external", url: new URL(params.url).href } };
42
+ },
43
+ });
@@ -0,0 +1,186 @@
1
+ import type { SessionSummary, SnapshotParams } from "../../../domain/capabilities/specs.ts";
2
+ import { PageError } from "../../../domain/errors.ts";
3
+ import type { JsonValue } from "../../../domain/json/strict-json.ts";
4
+ import { LIMITS } from "../../../domain/limits.ts";
5
+ import type { SessionRecord } from "../../../host/types.ts";
6
+ import { ENTRY_FILE, joinPath } from "../../../pages/layout.ts";
7
+ import { handler, type HandlerContext } from "../handler.ts";
8
+
9
+ /** Read capabilities. spec 05 §Reads */
10
+
11
+ export const contextGet = handler<null, unknown>({
12
+ method: "context.get",
13
+ async execute(_params, { serving, session, page }) {
14
+ return {
15
+ result: {
16
+ protocolVersion: 1,
17
+ session: { id: session.id, title: session.title.slice(0, LIMITS.titleChars), projectId: session.projectId },
18
+ page: { revision: page.revision, readOnly: page.stale },
19
+ capabilities: serving.registry.descriptors(),
20
+ },
21
+ };
22
+ },
23
+ });
24
+
25
+ export const sessionActivity = handler<{ limit: number }, unknown>({
26
+ method: "session.activity",
27
+ async execute(params, { serving, session }) {
28
+ const items = await serving.host.sessions.activity(session.id, params.limit);
29
+ return { result: { state: session.state, updatedAtMs: session.updatedAtMs, items } };
30
+ },
31
+ });
32
+
33
+ // --- sessions.snapshot -------------------------------------------------------
34
+
35
+ interface Cursor {
36
+ readonly phase: "live" | "archived";
37
+ readonly offset: number;
38
+ readonly query: string;
39
+ }
40
+
41
+ function queryKey(params: SnapshotParams): string {
42
+ return `${params.projectId ?? ""}|${params.includeArchived ? 1 : 0}|${params.includeChildren ? 1 : 0}`;
43
+ }
44
+
45
+ export function encodeCursor(cursor: Cursor): string {
46
+ return Buffer.from(JSON.stringify(cursor), "utf8").toString("base64url");
47
+ }
48
+
49
+ export function decodeCursor(value: string, params: SnapshotParams): Cursor {
50
+ try {
51
+ const parsed = JSON.parse(Buffer.from(value, "base64url").toString("utf8")) as Partial<Cursor>;
52
+ if ((parsed.phase !== "live" && parsed.phase !== "archived") || !Number.isSafeInteger(parsed.offset) || (parsed.offset as number) < 0 || parsed.query !== queryKey(params)) {
53
+ throw new Error("mismatch");
54
+ }
55
+ return { phase: parsed.phase, offset: parsed.offset as number, query: parsed.query };
56
+ } catch {
57
+ throw new PageError("invalid_params", "The cursor does not belong to this query");
58
+ }
59
+ }
60
+
61
+ async function pageAvailability(context: HandlerContext, sessions: readonly SessionRecord[]): Promise<Map<string, boolean>> {
62
+ const { serving } = context;
63
+ const byHost = new Map<string, { session: string; path: string }[]>();
64
+ await Promise.all(
65
+ sessions.map(async (session) => {
66
+ try {
67
+ const location = await serving.host.sessions.storage(session.id);
68
+ const entries = byHost.get(location.hostId) ?? [];
69
+ entries.push({ session: session.id, path: joinPath(location.rootPath, ENTRY_FILE) });
70
+ byHost.set(location.hostId, entries);
71
+ } catch {
72
+ // No storage yet: no page.
73
+ }
74
+ }),
75
+ );
76
+ const availability = new Map<string, boolean>();
77
+ await Promise.all(
78
+ [...byHost.entries()].map(async ([hostId, entries]) => {
79
+ const existence = await serving.host.files.exist(hostId, entries.map((entry) => entry.path));
80
+ for (const entry of entries) availability.set(entry.session, existence[entry.path] === true);
81
+ }),
82
+ );
83
+ return availability;
84
+ }
85
+
86
+ export const sessionsSnapshot = handler<SnapshotParams, unknown>({
87
+ method: "sessions.snapshot",
88
+ async execute(params, context) {
89
+ const { serving } = context;
90
+ const start: Cursor = params.cursor ? decodeCursor(params.cursor, params) : { phase: "live", offset: 0, query: queryKey(params) };
91
+ const collected: SessionRecord[] = [];
92
+ let phase = start.phase;
93
+ let offset = start.offset;
94
+ let next: Cursor | null = null;
95
+ while (collected.length < params.limit) {
96
+ const want = params.limit - collected.length;
97
+ const rows = await serving.host.sessions.list({
98
+ ...(params.projectId ? { projectId: params.projectId } : {}),
99
+ archived: phase === "archived",
100
+ rootsOnly: !params.includeChildren,
101
+ offset,
102
+ limit: want + 1,
103
+ });
104
+ const visible = rows.filter(
105
+ (row) => row.visibility === "visible" && !row.deleted && row.archived === (phase === "archived") && (params.includeChildren || row.parentId === null),
106
+ );
107
+ const more = rows.length > want;
108
+ collected.push(...visible.slice(0, want));
109
+ offset += Math.min(rows.length, want);
110
+ if (more) {
111
+ next = { phase, offset, query: start.query };
112
+ break;
113
+ }
114
+ if (phase === "live" && params.includeArchived) {
115
+ phase = "archived";
116
+ offset = 0;
117
+ continue;
118
+ }
119
+ break;
120
+ }
121
+ const availability = await pageAvailability(context, collected);
122
+ const sessions: SessionSummary[] = collected.map((record) => ({
123
+ id: record.id,
124
+ title: record.title.slice(0, LIMITS.titleChars),
125
+ projectId: record.projectId,
126
+ parentSessionId: record.parentId,
127
+ status: record.state,
128
+ archived: record.archived,
129
+ page: { available: availability.get(record.id) === true, revision: availability.get(record.id) ? serving.pages.knownRevision(record.id) : null },
130
+ updatedAtMs: record.updatedAtMs,
131
+ attentionAtMs: record.attentionAtMs,
132
+ unread: record.unread,
133
+ }));
134
+ return { result: { sessions, nextCursor: next ? encodeCursor(next) : null, generatedAtMs: serving.now() } };
135
+ },
136
+ });
137
+
138
+ export const projectsList = handler<null, unknown>({
139
+ method: "projects.list",
140
+ async execute(_params, { serving }) {
141
+ const projects = await serving.host.projects.list();
142
+ return { result: { projects: projects.slice(0, LIMITS.projectsMax).map((project) => ({ id: project.id, name: project.name.slice(0, LIMITS.titleChars), kind: project.kind })) } };
143
+ },
144
+ });
145
+
146
+ export const providersList = handler<null, unknown>({
147
+ method: "providers.list",
148
+ async execute(_params, { serving }) {
149
+ const providers = await serving.host.providers.list();
150
+ return {
151
+ result: {
152
+ providers: providers.slice(0, LIMITS.providersMax).map((provider) => ({
153
+ id: provider.id,
154
+ displayName: provider.displayName.slice(0, LIMITS.titleChars),
155
+ available: provider.available,
156
+ models: provider.models.slice(0, LIMITS.modelsPerProvider).map((model) => ({
157
+ id: model.id,
158
+ displayName: model.displayName.slice(0, LIMITS.titleChars),
159
+ isDefault: model.isDefault,
160
+ reasoningLevels: model.reasoningLevels.slice(0, 16),
161
+ })),
162
+ })),
163
+ },
164
+ };
165
+ },
166
+ });
167
+
168
+ export function storageKey(session: string, key: string): string {
169
+ return `state:${session}:${key}`;
170
+ }
171
+
172
+ export const storageGet = handler<{ key: string }, unknown>({
173
+ method: "storage.get",
174
+ async execute(params, { serving, session }) {
175
+ const stored = await serving.host.kv.get(storageKey(session.id, params.key));
176
+ return { result: stored === undefined ? { found: false } : { found: true, value: stored } };
177
+ },
178
+ });
179
+
180
+ export const storageSet = handler<{ key: string; value: JsonValue }, unknown>({
181
+ method: "storage.set",
182
+ async execute(params, { serving, session }) {
183
+ await serving.host.kv.set(storageKey(session.id, params.key), params.value);
184
+ return { result: { stored: true } };
185
+ },
186
+ });
@@ -0,0 +1,175 @@
1
+ import type { SessionsSendParams, SessionsStartParams } from "../../../domain/capabilities/specs.ts";
2
+ import { PageError } from "../../../domain/errors.ts";
3
+ import { fingerprint } from "../../../domain/json/canonical.ts";
4
+ import type { JsonValue } from "../../../domain/json/strict-json.ts";
5
+ import { formatReplyMessage } from "../../../domain/submissions/message.ts";
6
+ import type { SessionRecord, StartSessionArgs } from "../../../host/types.ts";
7
+ import { excerpt, handler, type HandlerContext } from "../handler.ts";
8
+
9
+ /** Writes to the page's own session, and the confirmed cross-session effects. spec 05 */
10
+
11
+ interface ReplyParams {
12
+ title?: string;
13
+ mode: "queue" | "steer";
14
+ result: JsonValue;
15
+ idempotencyKey?: string;
16
+ }
17
+
18
+ export const sessionReply = handler<ReplyParams, unknown>({
19
+ method: "session.reply",
20
+ async execute(params, { serving, session, page, requestId }) {
21
+ const key = `${session.id}:${params.idempotencyKey ?? requestId}`;
22
+ const print = fingerprint({ revision: page.revision, result: params.result, mode: params.mode, title: params.title ?? null });
23
+ const remembered = serving.replies.remember(key, print, () => serving.host.sessions.send(session.id, formatReplyMessage(params.title, params.result), params.mode), serving.now());
24
+ if (remembered.kind === "conflict") throw new PageError("conflict", "This idempotency key was already used with a different reply");
25
+ const outcome = await remembered.outcome;
26
+ return { result: { delivery: outcome.delivery, duplicate: remembered.kind === "replay" } };
27
+ },
28
+ });
29
+
30
+ async function targetSession(context: HandlerContext, id: string): Promise<SessionRecord> {
31
+ const target = await context.serving.host.sessions.get(id);
32
+ if (!target || target.deleted) throw new PageError("not_found", "That session is not available");
33
+ return target;
34
+ }
35
+
36
+ export const sessionsSend = handler<SessionsSendParams, unknown>({
37
+ method: "sessions.send",
38
+ async refuse(params, context) {
39
+ if (params.sessionId === context.session.id) throw new PageError("invalid_params", "Use session.reply to answer this page's own session");
40
+ await targetSession(context, params.sessionId);
41
+ },
42
+ async summarize(params, context) {
43
+ const target = await targetSession(context, params.sessionId);
44
+ return `Send to “${excerpt(target.title, 60)}”: “${excerpt(params.prompt)}”${params.mode === "steer" ? " (interrupting its current turn)" : ""}`;
45
+ },
46
+ async execute(params, { serving }) {
47
+ const sent = await serving.host.sessions.send(params.sessionId, params.prompt, params.mode);
48
+ return { result: { sessionId: params.sessionId, delivery: sent.delivery, duplicate: false } };
49
+ },
50
+ });
51
+
52
+ async function resolveStart(params: SessionsStartParams, context: HandlerContext): Promise<{ args: StartSessionArgs; projectName: string; environmentLabel: string }> {
53
+ const projects = await context.serving.host.projects.list();
54
+ const project = projects.find((candidate) => candidate.id === params.projectId);
55
+ if (!project) throw new PageError("not_found", "That project is not available");
56
+ let environment: StartSessionArgs["environment"] = { kind: "project-default" };
57
+ let environmentLabel = "the project's default environment";
58
+ if (typeof params.environment === "object") {
59
+ const other = await targetSession(context, params.environment.sameAs);
60
+ if (!other.environmentId) throw new PageError("invalid_params", "That session has no environment to share");
61
+ environment = { kind: "reuse", environmentId: other.environmentId };
62
+ environmentLabel = `the environment of “${excerpt(other.title, 40)}”`;
63
+ }
64
+ return {
65
+ args: {
66
+ projectId: params.projectId,
67
+ prompt: params.prompt,
68
+ ...(params.title ? { title: params.title } : {}),
69
+ ...(params.providerId ? { providerId: params.providerId } : {}),
70
+ ...(params.model ? { model: params.model } : {}),
71
+ ...(params.reasoningLevel ? { reasoningLevel: params.reasoningLevel } : {}),
72
+ environment,
73
+ },
74
+ projectName: project.name,
75
+ environmentLabel,
76
+ };
77
+ }
78
+
79
+ export const sessionsStart = handler<SessionsStartParams, unknown>({
80
+ method: "sessions.start",
81
+ async refuse(params, context) {
82
+ await resolveStart(params, context);
83
+ },
84
+ async summarize(params, context) {
85
+ const { projectName, environmentLabel } = await resolveStart(params, context);
86
+ const runtime = [params.providerId, params.model, params.reasoningLevel].filter(Boolean).join(" · ") || "the project's default provider and model";
87
+ return `Start a session in ${projectName}: “${excerpt(params.prompt)}” — using ${runtime}, in ${environmentLabel}`;
88
+ },
89
+ async execute(params, context) {
90
+ const { args } = await resolveStart(params, context);
91
+ const started = await context.serving.host.sessions.start(args);
92
+ return { result: { sessionId: started.id } };
93
+ },
94
+ });
95
+
96
+ export const sessionsStop = handler<{ sessionId: string }, unknown>({
97
+ method: "sessions.stop",
98
+ async refuse(params, context) {
99
+ if (params.sessionId === context.session.id) throw new PageError("invalid_params", "A page cannot stop its own session");
100
+ await targetSession(context, params.sessionId);
101
+ },
102
+ async summarize(params, context) {
103
+ const target = await targetSession(context, params.sessionId);
104
+ return `Stop “${excerpt(target.title, 60)}”`;
105
+ },
106
+ async execute(params, { serving }) {
107
+ await serving.host.sessions.stop(params.sessionId);
108
+ return { result: { stopped: true } };
109
+ },
110
+ });
111
+
112
+ export const sessionsArchive = handler<{ sessionId: string }, unknown>({
113
+ method: "sessions.archive",
114
+ async refuse(params, context) {
115
+ await targetSession(context, params.sessionId);
116
+ },
117
+ async summarize(params, context) {
118
+ const target = await targetSession(context, params.sessionId);
119
+ return `Archive “${excerpt(target.title, 60)}”${params.sessionId === context.session.id ? " (this page's own session; its page will stop being served)" : ""}`;
120
+ },
121
+ async execute(params, { serving }) {
122
+ await serving.host.sessions.archive(params.sessionId);
123
+ return { result: { archived: true } };
124
+ },
125
+ });
126
+
127
+ export const sessionsMarkRead = handler<{ sessionId: string; read: boolean }, unknown>({
128
+ method: "sessions.markRead",
129
+ async refuse(params, context) {
130
+ await targetSession(context, params.sessionId);
131
+ },
132
+ async execute(params, { serving }) {
133
+ const after = await serving.host.sessions.markRead(params.sessionId, params.read);
134
+ return { result: { sessionId: params.sessionId, unread: after.unread } };
135
+ },
136
+ });
137
+
138
+ export const projectsBrowse = handler<null, unknown>({
139
+ method: "projects.browse",
140
+ async summarize() {
141
+ return "Choose a project folder on this device";
142
+ },
143
+ async execute(_params, { serving, session }) {
144
+ const location = await serving.host.sessions.storage(session.id);
145
+ const picked = await serving.host.projects.browse(location.hostId);
146
+ if (!picked) return { result: { selection: null } };
147
+ const token = serving.selections.issue({ session: session.id, hostId: location.hostId, path: picked.path }, serving.now());
148
+ return { result: { selection: { token, displayPath: displayPath(picked.path), hostName: picked.hostName } } };
149
+ },
150
+ });
151
+
152
+ function displayPath(path: string): string {
153
+ return path.replace(/^\/Users\/[^/]+/, "~").replace(/^\/home\/[^/]+/, "~").replace(/^[A-Za-z]:\\Users\\[^\\]+/, "~");
154
+ }
155
+
156
+ export const projectsCreate = handler<{ selectionToken: string; name?: string }, unknown>({
157
+ method: "projects.create",
158
+ async refuse(params, { serving, session }) {
159
+ if (!serving.selections.peek(params.selectionToken, session.id, serving.now())) {
160
+ throw new PageError("not_found", "That folder selection has expired; choose the folder again");
161
+ }
162
+ },
163
+ async summarize(params, { serving, session }) {
164
+ const selection = serving.selections.peek(params.selectionToken, session.id, serving.now());
165
+ const name = params.name ?? selection?.path.split(/[\\/]/).pop() ?? "the selected folder";
166
+ return `Create project “${excerpt(name, 60)}” from ${selection ? displayPath(selection.path) : "the selected folder"}`;
167
+ },
168
+ async execute(params, { serving, session }) {
169
+ const selection = serving.selections.redeem(params.selectionToken, session.id, serving.now());
170
+ if (!selection) throw new PageError("not_found", "That folder selection has expired; choose the folder again");
171
+ const name = params.name ?? selection.path.split(/[\\/]/).pop() ?? "New project";
172
+ const created = await serving.host.projects.create({ name, hostId: selection.hostId, path: selection.path });
173
+ return { result: { project: { id: created.id, name: created.name, kind: created.kind } } };
174
+ },
175
+ });
@@ -0,0 +1,58 @@
1
+ import { randomBytes } from "node:crypto";
2
+ import { LIMITS } from "../../domain/limits.ts";
3
+
4
+ /**
5
+ * Folder-picker selections, held server-side so a page only ever sees an
6
+ * opaque token: single use, short-lived, bound to the requesting session.
7
+ * spec R5.35–R5.37
8
+ */
9
+ export interface Selection {
10
+ readonly session: string;
11
+ readonly hostId: string;
12
+ readonly path: string;
13
+ readonly expiresAt: number;
14
+ }
15
+
16
+ export interface SelectionStore {
17
+ issue(selection: Omit<Selection, "expiresAt">, now: number): string;
18
+ /** Looks a selection up without consuming it. */
19
+ peek(token: string, session: string, now: number): Selection | null;
20
+ /** Returns and forgets the selection when the token is valid for the session. */
21
+ redeem(token: string, session: string, now: number): Selection | null;
22
+ }
23
+
24
+ export function createSelectionStore(): SelectionStore {
25
+ const selections = new Map<string, Selection>();
26
+
27
+ function prune(now: number): void {
28
+ for (const [token, selection] of selections) {
29
+ if (selection.expiresAt <= now) selections.delete(token);
30
+ }
31
+ while (selections.size > LIMITS.selectionTokens) {
32
+ const oldest = selections.keys().next().value;
33
+ if (oldest === undefined) break;
34
+ selections.delete(oldest);
35
+ }
36
+ }
37
+
38
+ return {
39
+ issue(selection, now) {
40
+ prune(now);
41
+ const token = `sel.${randomBytes(18).toString("base64url")}`;
42
+ selections.set(token, { ...selection, expiresAt: now + LIMITS.selectionTokenMs });
43
+ return token;
44
+ },
45
+ peek(token, session, now) {
46
+ prune(now);
47
+ const selection = selections.get(token);
48
+ return selection && selection.session === session ? selection : null;
49
+ },
50
+ redeem(token, session, now) {
51
+ prune(now);
52
+ const selection = selections.get(token);
53
+ if (!selection || selection.session !== session) return null;
54
+ selections.delete(token);
55
+ return selection;
56
+ },
57
+ };
58
+ }