@cydm/happy-elves 0.1.0-beta.47 → 0.1.0-beta.49

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 (55) hide show
  1. package/apps/cli/dist/commands/app.js +5 -1
  2. package/apps/cli/dist/commands/config.js +2 -2
  3. package/apps/cli/dist/commands/lib/args.js +7 -0
  4. package/apps/cli/dist/commands/lib/config.d.ts +1 -0
  5. package/apps/cli/dist/commands/lib/config.js +15 -2
  6. package/apps/cli/dist/commands/lib/doctor.js +3 -3
  7. package/apps/cli/dist/commands/lib/exit.js +2 -1
  8. package/apps/cli/dist/commands/lib/index.d.ts +1 -0
  9. package/apps/cli/dist/commands/lib/index.js +1 -0
  10. package/apps/cli/dist/commands/lib/json.d.ts +1 -1
  11. package/apps/cli/dist/commands/lib/json.js +2 -2
  12. package/apps/cli/dist/commands/lib/orchestrator.d.ts +30 -0
  13. package/apps/cli/dist/commands/lib/orchestrator.js +146 -0
  14. package/apps/cli/dist/commands/lib/relay-http.js +15 -4
  15. package/apps/cli/dist/commands/lib/session-output.d.ts +1 -0
  16. package/apps/cli/dist/commands/lib/session-output.js +42 -10
  17. package/apps/cli/dist/commands/lib/status.js +3 -3
  18. package/apps/cli/dist/commands/lib/types.d.ts +1 -0
  19. package/apps/cli/dist/commands/lib/usage.js +46 -16
  20. package/apps/cli/dist/commands/orchestrator.d.ts +2 -0
  21. package/apps/cli/dist/commands/orchestrator.js +411 -0
  22. package/apps/cli/dist/commands/relay.js +5 -5
  23. package/apps/cli/dist/commands/remote.js +2 -2
  24. package/apps/cli/dist/commands/skill.d.ts +2 -0
  25. package/apps/cli/dist/commands/skill.js +214 -0
  26. package/apps/cli/dist/commands/turn.js +5 -2
  27. package/apps/daemon/dist/session/directory.d.ts +4 -0
  28. package/apps/daemon/dist/session/directory.js +30 -3
  29. package/apps/daemon/package.json +1 -1
  30. package/apps/relay/dist/connections.d.ts +1 -0
  31. package/apps/relay/dist/controller-handlers.js +4 -1
  32. package/apps/relay/dist/http-routes.js +32 -1
  33. package/apps/relay/dist/http-schemas.d.ts +16 -0
  34. package/apps/relay/dist/http-schemas.js +5 -0
  35. package/apps/relay/dist/relay-context.js +2 -0
  36. package/apps/relay/dist/session-projection-reducer.js +1 -1
  37. package/npm-shrinkwrap.json +5 -5
  38. package/package.json +1 -1
  39. package/packages/client/dist/client.d.ts +5 -0
  40. package/packages/client/dist/client.js +35 -7
  41. package/packages/client/dist/http.d.ts +1 -0
  42. package/packages/client/dist/http.js +15 -3
  43. package/packages/client/dist/live-session-state.d.ts +15 -0
  44. package/packages/client/dist/live-session-state.js +60 -0
  45. package/packages/client/dist/live-state.d.ts +8 -0
  46. package/packages/client/dist/live-state.js +33 -0
  47. package/packages/client/dist/live-types.d.ts +113 -0
  48. package/packages/client/dist/live-types.js +1 -0
  49. package/packages/client/dist/live.d.ts +2 -99
  50. package/packages/client/dist/live.js +16 -77
  51. package/packages/client/dist/types.d.ts +4 -1
  52. package/packages/client/dist/validation.d.ts +1 -0
  53. package/packages/client/dist/validation.js +11 -0
  54. package/packages/shared/dist/protocol.d.ts +3 -0
  55. package/packages/shared/dist/protocol.js +1 -0
@@ -1,4 +1,4 @@
1
- import { CliError, ControllerClient, DEFAULT_COMMAND_TIMEOUT_MS, compactText, displayPath, ok, parseDurationMs, parsePermissionMode, printConciseEvents, readConfig, readPromptTextFile, rebuildTurnOutput, requirePositional, requireProjectedSession, requireString, structuredOutputForRunningTurn, structuredOutputForStoredTurn, structuredOutputForTurn, summarizeTurns, wantsJson, wantsVerbose, writeTurnOutput } from "./lib/index.js";
1
+ import { CliError, ControllerClient, DEFAULT_COMMAND_TIMEOUT_MS, collectExactTurnEvents, compactText, displayPath, ok, parseDurationMs, parsePermissionMode, printConciseEvents, readConfig, readPromptTextFile, rebuildTurnOutput, requirePositional, requireProjectedSession, requireString, structuredOutputForRunningTurn, structuredOutputForStoredTurn, structuredOutputForTurn, summarizeTurns, wantsJson, wantsVerbose, writeTurnOutput } from "./lib/index.js";
2
2
  function formatTurnTimestamp(value) {
3
3
  return value === undefined ? "-" : new Date(value).toISOString();
4
4
  }
@@ -46,6 +46,9 @@ function compactTurn(turn) {
46
46
  async function readTurnSummary(client, sessionId, turnId) {
47
47
  return summarizeTurns(await client.history(sessionId, 1000)).find((turn) => turn.turnId === turnId);
48
48
  }
49
+ async function readExactTurnSummary(client, sessionId, turnId) {
50
+ return summarizeTurns(await collectExactTurnEvents(client, sessionId, turnId, 1000)).find((turn) => turn.turnId === turnId);
51
+ }
49
52
  async function resolveTurnOrCheckpointId(client, sessionId, turnOrCheckpointId) {
50
53
  const turn = await readTurnSummary(client, sessionId, turnOrCheckpointId);
51
54
  return turn?.checkpointId ?? turn?.runtimeTurnId ?? turnOrCheckpointId;
@@ -53,7 +56,7 @@ async function resolveTurnOrCheckpointId(client, sessionId, turnOrCheckpointId)
53
56
  async function waitForTurn(client, sessionId, turnId, timeoutMs) {
54
57
  const startedAt = Date.now();
55
58
  while (true) {
56
- const summary = await readTurnSummary(client, sessionId, turnId);
59
+ const summary = await readExactTurnSummary(client, sessionId, turnId);
57
60
  if (summary && summary.status !== "running")
58
61
  return summary;
59
62
  if (Date.now() - startedAt >= timeoutMs) {
@@ -1,4 +1,8 @@
1
1
  import type { MachineCommand } from "../../../../packages/shared/dist/index.js";
2
2
  import type { DaemonConfig, DirectoryListResult } from "../types.js";
3
+ export declare function availableWindowsDriveRoots(options?: {
4
+ platform?: NodeJS.Platform;
5
+ access?: (target: string) => Promise<unknown>;
6
+ }): Promise<string[]>;
3
7
  export declare function buildDirectoryList(config: DaemonConfig, requestedPath?: string): Promise<DirectoryListResult>;
4
8
  export declare function handleListDirectory(ws: WebSocket, config: DaemonConfig, command: MachineCommand): Promise<void>;
@@ -5,6 +5,7 @@ import { claimCommandRequest, sendCommandResponse } from "../relay/send.js";
5
5
  import { isUncPath, workspaceRealRoots } from "./workspace-paths.js";
6
6
  const directoryEntryLimit = 500;
7
7
  const directoryStatConcurrency = 16;
8
+ const windowsDriveLetters = "ABCDEFGHIJKLMNOPQRSTUVWXYZ";
8
9
  async function findRepoRoot(startPath) {
9
10
  let current = startPath;
10
11
  while (true) {
@@ -40,12 +41,38 @@ async function directoryRoots(workspaceRoots, currentPath) {
40
41
  for (const root of workspaceRoots)
41
42
  await add(root);
42
43
  await add(os.homedir(), "home");
43
- await add(path.parse(currentPath).root);
44
+ const currentRoot = path.parse(currentPath).root;
45
+ await add(currentRoot, filesystemRootName(currentRoot));
44
46
  const homeRoot = path.parse(os.homedir()).root;
45
- if (homeRoot !== path.parse(currentPath).root)
46
- await add(homeRoot);
47
+ if (homeRoot !== currentRoot)
48
+ await add(homeRoot, filesystemRootName(homeRoot));
49
+ for (const driveRoot of await availableWindowsDriveRoots())
50
+ await add(driveRoot, filesystemRootName(driveRoot));
47
51
  return roots;
48
52
  }
53
+ export async function availableWindowsDriveRoots(options = {}) {
54
+ const platform = options.platform ?? process.platform;
55
+ if (platform !== "win32")
56
+ return [];
57
+ const access = options.access ?? ((target) => fs.access(target));
58
+ const roots = await Promise.all([...windowsDriveLetters].map(async (letter) => {
59
+ const root = `${letter}:\\`;
60
+ try {
61
+ await access(root);
62
+ return root;
63
+ }
64
+ catch {
65
+ return undefined;
66
+ }
67
+ }));
68
+ return roots.filter((root) => Boolean(root));
69
+ }
70
+ function filesystemRootName(root) {
71
+ const match = /^([A-Za-z]:)[\\/]*$/u.exec(root);
72
+ if (match)
73
+ return match[1];
74
+ return root;
75
+ }
49
76
  export async function buildDirectoryList(config, requestedPath) {
50
77
  if (requestedPath && isUncPath(requestedPath)) {
51
78
  throw Object.assign(new Error("UNC paths are not supported for directory browsing."), { code: "DIRECTORY_UNSUPPORTED_PATH" });
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cydm/happy-elves-daemon",
3
- "version": "0.1.0-beta.47",
3
+ "version": "0.1.0-beta.49",
4
4
  "private": true,
5
5
  "type": "module"
6
6
  }
@@ -10,6 +10,7 @@ export type PendingCommandContext = {
10
10
  cwd?: string;
11
11
  machineId: string;
12
12
  name?: string;
13
+ promptHash?: string;
13
14
  recipientDeviceId?: string;
14
15
  requestId: string;
15
16
  runtimeSessionId?: string;
@@ -172,6 +172,7 @@ export function handleControllerMessage(context, connection, message) {
172
172
  markCommandPending(connection.accountId, sessionRow.machine_id, message.requestId, {
173
173
  action: "session.run",
174
174
  blocksSessionTurn: true,
175
+ promptHash: message.promptHash,
175
176
  recipientDeviceId: connection.deviceId ?? undefined,
176
177
  sessionId: sessionRow.id,
177
178
  turnId: message.turnId,
@@ -729,7 +730,7 @@ function controllerMessageCommandContext(message, targetMachineId) {
729
730
  case "controller:createSession":
730
731
  return { action: "session.create", agent: message.agent, cwd: message.cwd, machineId: message.machineId, name: message.name };
731
732
  case "controller:prompt":
732
- return { action: "session.run", machineId: targetMachineId, sessionId: message.sessionId, turnId: message.turnId };
733
+ return { action: "session.run", machineId: targetMachineId, promptHash: message.promptHash, sessionId: message.sessionId, turnId: message.turnId };
733
734
  case "controller:cancel":
734
735
  return { action: "session.cancel", machineId: targetMachineId, sessionId: message.sessionId, turnId: message.turnId };
735
736
  case "controller:resume":
@@ -790,6 +791,8 @@ function pendingCommandMatchesMessage(pending, expected) {
790
791
  return false;
791
792
  if (!matchesDefined(pending.name, expected.name))
792
793
  return false;
794
+ if (!matchesDefined(pending.promptHash, expected.promptHash))
795
+ return false;
793
796
  if (!matchesDefined(pending.runtimeSessionId, expected.runtimeSessionId))
794
797
  return false;
795
798
  if (!commandSessionMatches(pending, expected))
@@ -1,6 +1,6 @@
1
1
  import { z } from "zod";
2
2
  import { encryptedEnvelopeSchema, pairingCode, randomId, } from "../../../packages/shared/dist/index.js";
3
- import { controllerDeviceSchema, controllerInviteClaimSchema, controllerInviteCreateSchema, deviceRevokeSchema, pairingClaimSchema, pairingStartSchema, scopedTokenCreateSchema, scopedTokenRevokeSchema, sessionHeadAdvanceSchema, sessionEventsQuerySchema, } from "./http-schemas.js";
3
+ import { controllerDeviceSchema, controllerInviteClaimSchema, controllerInviteCreateSchema, deviceRevokeSchema, pairingClaimSchema, pairingStartSchema, scopedTokenCreateSchema, scopedTokenRevokeSchema, sessionHeadAdvanceSchema, sessionMetadataUpdateSchema, sessionEventsQuerySchema, } from "./http-schemas.js";
4
4
  import { HttpError } from "./errors.js";
5
5
  import { sessionEventLogicalMessageKey, sessionEventLogicalSeq, sessionEventLogicalSeqValue, sessionEventLogicalTime, sessionEventLogicalTimeValue, visibleSessionEventWhere, } from "./event-visibility.js";
6
6
  import { encodePairingCode, isPairingCodeMatch, tokenDigest } from "./security.js";
@@ -360,6 +360,37 @@ export function registerHttpRoutes(app, context, controllerInviteTtlMs) {
360
360
  ...(rewriteFenced ? { reason: "rewrite-fenced" } : {}),
361
361
  };
362
362
  });
363
+ app.patch("/api/sessions/:sessionId/metadata", async (request) => {
364
+ const token = context.requireFullControllerToken(request.headers.authorization);
365
+ const params = z.object({ sessionId: z.string().min(1) }).parse(request.params);
366
+ const body = sessionMetadataUpdateSchema.parse(request.body);
367
+ const session = context.db
368
+ .prepare("SELECT * FROM sessions WHERE account_id = ? AND id = ?")
369
+ .get(token.account_id, params.sessionId);
370
+ if (!session)
371
+ throw new HttpError(404, "SESSION_NOT_FOUND", "Session not found");
372
+ const ts = context.now();
373
+ const result = body.expectedUpdatedAt === undefined
374
+ ? context.db.prepare(`UPDATE sessions
375
+ SET encrypted_metadata = ?,
376
+ updated_at = ?
377
+ WHERE account_id = ? AND id = ?`).run(JSON.stringify(body.encryptedMetadata), ts, token.account_id, params.sessionId)
378
+ : context.db.prepare(`UPDATE sessions
379
+ SET encrypted_metadata = ?,
380
+ updated_at = ?
381
+ WHERE account_id = ? AND id = ? AND updated_at = ?`).run(JSON.stringify(body.encryptedMetadata), ts, token.account_id, params.sessionId, body.expectedUpdatedAt);
382
+ if (result.changes === 0) {
383
+ throw new HttpError(409, "SESSION_METADATA_CONFLICT", "Session metadata changed; retry with the latest session");
384
+ }
385
+ const row = context.db
386
+ .prepare("SELECT * FROM sessions WHERE account_id = ? AND id = ?")
387
+ .get(token.account_id, params.sessionId);
388
+ if (!row)
389
+ throw new HttpError(404, "SESSION_NOT_FOUND", "Session not found");
390
+ const snapshot = context.sessionSnapshot(row);
391
+ context.broadcastControllers(token.account_id, { type: "server:session", session: snapshot });
392
+ return { session: snapshot };
393
+ });
363
394
  app.get("/api/devices", async (request) => {
364
395
  const token = context.requireFullControllerToken(request.headers.authorization);
365
396
  const rows = context.db
@@ -69,3 +69,19 @@ export declare const sessionHeadAdvanceSchema: z.ZodObject<{
69
69
  occurredAt: z.ZodOptional<z.ZodNumber>;
70
70
  }, z.core.$strip>;
71
71
  }, z.core.$strip>;
72
+ export declare const sessionMetadataUpdateSchema: z.ZodObject<{
73
+ encryptedMetadata: z.ZodObject<{
74
+ v: z.ZodLiteral<1>;
75
+ alg: z.ZodLiteral<"A256GCM">;
76
+ scope: z.ZodString;
77
+ key: z.ZodObject<{
78
+ nonce: z.ZodString;
79
+ ciphertext: z.ZodString;
80
+ }, z.core.$strip>;
81
+ data: z.ZodObject<{
82
+ nonce: z.ZodString;
83
+ ciphertext: z.ZodString;
84
+ }, z.core.$strip>;
85
+ }, z.core.$strip>;
86
+ expectedUpdatedAt: z.ZodOptional<z.ZodNumber>;
87
+ }, z.core.$strip>;
@@ -1,4 +1,5 @@
1
1
  import { z } from "zod";
2
+ import { encryptedEnvelopeSchema } from "../../../packages/shared/dist/index.js";
2
3
  export const pairingStartSchema = z.object({
3
4
  deviceId: z.string().min(1),
4
5
  deviceName: z.string().min(1),
@@ -67,3 +68,7 @@ export const sessionHeadAdvanceSchema = z.object({
67
68
  occurredAt: z.number().int().optional(),
68
69
  }),
69
70
  });
71
+ export const sessionMetadataUpdateSchema = z.object({
72
+ encryptedMetadata: encryptedEnvelopeSchema,
73
+ expectedUpdatedAt: z.number().int().positive().optional(),
74
+ });
@@ -286,6 +286,7 @@ export function createRelayContext(options) {
286
286
  ...(pending.cwd !== undefined ? { cwd: pending.cwd } : {}),
287
287
  machineId: pending.machineId,
288
288
  ...(pending.name !== undefined ? { name: pending.name } : {}),
289
+ ...(pending.promptHash !== undefined ? { promptHash: pending.promptHash } : {}),
289
290
  ...(pending.runtimeSessionId !== undefined ? { runtimeSessionId: pending.runtimeSessionId } : {}),
290
291
  ...(pending.sessionId !== undefined ? { sessionId: pending.sessionId } : {}),
291
292
  ...(pending.shell !== undefined ? { shell: pending.shell } : {}),
@@ -319,6 +320,7 @@ export function createRelayContext(options) {
319
320
  ...(typeof parsed.cwd === "string" ? { cwd: parsed.cwd } : {}),
320
321
  machineId: parsed.machineId,
321
322
  ...(typeof parsed.name === "string" ? { name: parsed.name } : {}),
323
+ ...(typeof parsed.promptHash === "string" ? { promptHash: parsed.promptHash } : {}),
322
324
  requestId: "",
323
325
  ...(typeof parsed.runtimeSessionId === "string" ? { runtimeSessionId: parsed.runtimeSessionId } : {}),
324
326
  ...(typeof parsed.sessionId === "string" ? { sessionId: parsed.sessionId } : {}),
@@ -338,7 +338,7 @@ function handleTurnDone(context, connection, message, acknowledgeMachineMessage)
338
338
  sessionId: message.sessionId,
339
339
  machineId: connection.machineId,
340
340
  requestTurnId: message.turnId,
341
- runtimeTurnId: message.lastTurnId ?? message.currentHead,
341
+ runtimeTurnId: message.lastTurnId,
342
342
  });
343
343
  }
344
344
  const runtimeTurnId = message.lastTurnId ?? message.currentHead ?? message.turnId;
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@cydm/happy-elves",
3
- "version": "0.1.0-beta.47",
3
+ "version": "0.1.0-beta.49",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@cydm/happy-elves",
9
- "version": "0.1.0-beta.47",
9
+ "version": "0.1.0-beta.49",
10
10
  "license": "Apache-2.0",
11
11
  "dependencies": {
12
12
  "@fastify/cors": "11.2.0",
@@ -1021,9 +1021,9 @@
1021
1021
  }
1022
1022
  },
1023
1023
  "node_modules/fast-uri": {
1024
- "version": "3.1.2",
1025
- "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz",
1026
- "integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==",
1024
+ "version": "3.1.3",
1025
+ "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.3.tgz",
1026
+ "integrity": "sha512-i70LwGWUduXqzicKXWshooq+sWL1K3WUU5rKZNG/0i3a1OSoX3HqhH5WbWwTmqWfor4urUakGPiRQcleRZTwOg==",
1027
1027
  "funding": [
1028
1028
  {
1029
1029
  "type": "github",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cydm/happy-elves",
3
- "version": "0.1.0-beta.47",
3
+ "version": "0.1.0-beta.49",
4
4
  "description": "Remote controller for local coding agents with hosted or self-hosted relay support.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -2,6 +2,9 @@ import { type DirectoryListing, type EncryptedSessionEvent, type HistoricalSessi
2
2
  import { sendCommand } from "./transport.js";
3
3
  import { type ControllerLiveOptions, type ControllerLiveProjection } from "./live.js";
4
4
  import type { AddControllerDeviceInput, AddControllerDeviceResult, CancelInput, CollectInput, CommandResult, ControllerClientConfig, ControllerInviteClaimInput, ControllerInviteClaimResult, ControllerInviteCreateInput, ControllerInviteCreateResult, ControllerSnapshot, CreateScopedTokenInput, CreateSessionInput, DecodedSessionEvent, DecodedSessionEventsPage, DeviceListResponse, DirectoryInput, FilePreviewInput, ForkInput, HistoricalSessionListPage, HistoricalSessionsInput, ImportHistoricalSessionInput, MachineMetadata, MachineDevExecInput, MachineDevExecResponse, MachineDiagnosticsResponse, RepairSessionHeadInput, RepairSessionHeadResult, RunOptions, ScopedTokenInfo, ScopedTokenSelf, SessionFilter, SessionMetadata, WaitInput } from "./types.js";
5
+ type UpdateSessionMetadataOptions = {
6
+ expectedUpdatedAt?: number;
7
+ };
5
8
  export declare class ControllerClient {
6
9
  readonly config: ControllerClientConfig;
7
10
  constructor(config: ControllerClientConfig);
@@ -30,6 +33,7 @@ export declare class ControllerClient {
30
33
  }>;
31
34
  listSessions(filter?: SessionFilter): Promise<SessionSnapshot[]>;
32
35
  getSession(sessionId: string): Promise<SessionSnapshot | undefined>;
36
+ updateSessionMetadata(sessionId: string, metadata: SessionMetadata, options?: UpdateSessionMetadataOptions): Promise<SessionSnapshot>;
33
37
  history(sessionId: string, limit?: number): Promise<DecodedSessionEvent[]>;
34
38
  createSession(input: CreateSessionInput): Promise<CommandResult>;
35
39
  collect(sessionId: string, input?: CollectInput): Promise<DecodedSessionEvent[]>;
@@ -62,3 +66,4 @@ export declare class ControllerClient {
62
66
  private mergeReplayedTurnEvents;
63
67
  private sessionEvents;
64
68
  }
69
+ export {};
@@ -1,12 +1,12 @@
1
1
  import { decryptJson, encryptJson, normalizeSessionName, parseServerMessage, randomId, } from "../../shared/dist/index.js";
2
2
  import { addControllerDevice, claimControllerInvite, createControllerInvite, createPairingCode, createScopedToken, listDevices, listScopedTokens, revokeDevice, revokeScopedToken, tokenSelf, } from "./account.js";
3
3
  import { ControllerClientError, ControllerCommandError } from "./errors.js";
4
- import { authenticatedJson, normalizeRelayUrl, readRelayJson, relayHttpError } from "./http.js";
4
+ import { authenticatedJson, normalizeRelayUrl, readRelayJson, relayFetch, relayHttpError } from "./http.js";
5
5
  import { parseRepairSessionHeadResult, parseSessionEventsResponse, } from "./parsers.js";
6
6
  import { sendAndWaitForDirectoryListing, sendAndWaitForFilePreview, sendAndWaitForHistoricalSessions, sendAndWaitForMachineDevExecResult, sendAndWaitForMachineDiagnostics, sendCommand, } from "./transport.js";
7
7
  import { ControllerLiveProjectionImpl } from "./live.js";
8
8
  import { DEFAULT_SESSION_CREATE_WAIT_MS, } from "./types.js";
9
- import { isWaitCondition, matchesWaitCondition, parseEventCursor, parseEventLimit, parsePositiveDuration, requireNonEmpty, sleep, } from "./validation.js";
9
+ import { isWaitCondition, matchesWaitCondition, parseEventCursor, parseEventLimit, parsePositiveDuration, parseSinceEventCursor, requireNonEmpty, sleep, } from "./validation.js";
10
10
  const DEFAULT_RUN_SETTLEMENT_WAIT_MS = 15_000;
11
11
  const DEFAULT_BLOCKING_RUN_WAIT_MS = 30 * 60 * 1000;
12
12
  function runSettlementTimeoutMs(options) {
@@ -16,6 +16,17 @@ function runSettlementTimeoutMs(options) {
16
16
  return options.runtimeTimeoutMs + DEFAULT_RUN_SETTLEMENT_WAIT_MS;
17
17
  return DEFAULT_BLOCKING_RUN_WAIT_MS;
18
18
  }
19
+ function parseSessionMetadataUpdateResult(value) {
20
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
21
+ throw new ControllerClientError("Session metadata response is invalid", "RELAY_RESPONSE_INVALID");
22
+ }
23
+ const session = value.session;
24
+ const message = parseServerMessage({ type: "server:session", session });
25
+ if (message.type !== "server:session") {
26
+ throw new ControllerClientError("Session metadata response is invalid", "RELAY_RESPONSE_INVALID");
27
+ }
28
+ return message.session;
29
+ }
19
30
  export class ControllerClient {
20
31
  config;
21
32
  constructor(config) {
@@ -27,9 +38,9 @@ export class ControllerClient {
27
38
  };
28
39
  }
29
40
  async snapshot() {
30
- const response = await fetch(`${this.config.relayUrl}/api/bootstrap`, {
41
+ const response = await relayFetch(`${this.config.relayUrl}/api/bootstrap`, {
31
42
  headers: { authorization: `Bearer ${this.config.controllerToken}` },
32
- });
43
+ }, "Could not reach relay");
33
44
  if (!response.ok) {
34
45
  throw await relayHttpError(response, "Bootstrap failed");
35
46
  }
@@ -99,6 +110,19 @@ export class ControllerClient {
99
110
  const checkedSessionId = requireNonEmpty(sessionId, "sessionId");
100
111
  return (await this.snapshot()).sessions.find((session) => session.id === checkedSessionId);
101
112
  }
113
+ async updateSessionMetadata(sessionId, metadata, options = {}) {
114
+ const checkedSessionId = requireNonEmpty(sessionId, "sessionId");
115
+ const requestId = randomId("req");
116
+ const encryptedMetadata = await encryptJson(this.config.accountSecret, `session:metadata:${checkedSessionId}:${requestId}`, metadata);
117
+ return await authenticatedJson(this.config, `/api/sessions/${encodeURIComponent(checkedSessionId)}/metadata`, {
118
+ method: "PATCH",
119
+ headers: { "content-type": "application/json" },
120
+ body: JSON.stringify({
121
+ encryptedMetadata,
122
+ ...(options.expectedUpdatedAt === undefined ? {} : { expectedUpdatedAt: options.expectedUpdatedAt }),
123
+ }),
124
+ }, parseSessionMetadataUpdateResult);
125
+ }
102
126
  async history(sessionId, limit = 20) {
103
127
  const checkedSessionId = requireNonEmpty(sessionId, "sessionId");
104
128
  const { events } = await this.sessionEvents(checkedSessionId, { limit: parseEventLimit(limit) });
@@ -142,7 +166,7 @@ export class ControllerClient {
142
166
  const checkedSessionId = requireNonEmpty(sessionId, "sessionId");
143
167
  const before = parseEventCursor(input.before);
144
168
  const beforeCursor = input.beforeCursor;
145
- const since = parseEventCursor(input.since);
169
+ const since = parseSinceEventCursor(input.since);
146
170
  const limit = parseEventLimit(input.limit);
147
171
  const turnId = input.turnId === undefined ? undefined : requireNonEmpty(input.turnId, "turnId");
148
172
  if (before !== undefined && beforeCursor !== undefined) {
@@ -154,6 +178,9 @@ export class ControllerClient {
154
178
  if (turnId !== undefined && (before !== undefined || beforeCursor !== undefined)) {
155
179
  throw new ControllerClientError("turnId cannot be combined with before cursors", "INVALID_ARGUMENT");
156
180
  }
181
+ if (turnId === undefined && typeof since === "string") {
182
+ throw new ControllerClientError("Logical since cursor requires turnId", "INVALID_ARGUMENT");
183
+ }
157
184
  const page = await this.sessionEvents(checkedSessionId, { before, beforeCursor, since, limit, turnId });
158
185
  const requestedLimit = limit ?? 20;
159
186
  const hasMoreBefore = page.hasMoreBefore ?? (since === undefined && page.events.length >= requestedLimit);
@@ -374,8 +401,8 @@ export class ControllerClient {
374
401
  async run(sessionId, text, options = {}) {
375
402
  const checkedSessionId = requireNonEmpty(sessionId, "sessionId");
376
403
  const promptText = requireNonEmpty(text, "text");
377
- const requestId = randomId("req");
378
- const turnId = randomId("turn");
404
+ const requestId = options.requestId === undefined ? randomId("req") : requireNonEmpty(options.requestId, "requestId");
405
+ const turnId = options.turnId === undefined ? randomId("turn") : requireNonEmpty(options.turnId, "turnId");
379
406
  const wait = options.wait ?? true;
380
407
  const timeoutMs = wait ? options.waitTimeoutMs : options.ackTimeoutMs;
381
408
  const result = await this.sendCommand({
@@ -387,6 +414,7 @@ export class ControllerClient {
387
414
  type: "user_prompt",
388
415
  text: promptText,
389
416
  }),
417
+ ...(options.promptHash === undefined ? {} : { promptHash: requireNonEmpty(options.promptHash, "promptHash") }),
390
418
  permissionMode: options.permissionMode ?? "approve-reads",
391
419
  timeoutMs: options.runtimeTimeoutMs,
392
420
  }, wait, timeoutMs);
@@ -2,6 +2,7 @@ import { ControllerClientError } from "./errors.js";
2
2
  import type { ControllerClientConfig } from "./types.js";
3
3
  export declare function normalizeRelayUrl(value: string): string;
4
4
  export declare function wsUrl(relayUrl: string): string;
5
+ export declare function relayFetch(input: string, init?: RequestInit, fallback?: string): Promise<Response>;
5
6
  export declare function relayHttpError(response: Response, fallback: string): Promise<ControllerClientError>;
6
7
  export declare function readRelayJson(response: Response, fallback: string): Promise<unknown>;
7
8
  export declare function authenticatedJson<T>(config: ControllerClientConfig, pathname: string, init: RequestInit, parse: (value: unknown) => T): Promise<T>;
@@ -24,6 +24,18 @@ export function wsUrl(relayUrl) {
24
24
  url.search = "";
25
25
  return url.toString();
26
26
  }
27
+ function networkErrorMessage(error) {
28
+ const message = error instanceof Error && error.message ? error.message : String(error);
29
+ return message.replace(/(https?:\/\/)([^/@\s]+)@/gu, "$1");
30
+ }
31
+ export async function relayFetch(input, init = {}, fallback = "Could not reach relay") {
32
+ try {
33
+ return await fetch(input, init);
34
+ }
35
+ catch (error) {
36
+ throw new ControllerClientError(`${fallback}: ${networkErrorMessage(error)}`, "RELAY_UNREACHABLE");
37
+ }
38
+ }
27
39
  export async function relayHttpError(response, fallback) {
28
40
  const text = await response.text();
29
41
  try {
@@ -60,20 +72,20 @@ export async function authenticatedJson(config, pathname, init, parse) {
60
72
  }
61
73
  }
62
74
  export async function authenticatedJsonRaw(config, pathname, init) {
63
- const response = await fetch(`${config.relayUrl}${pathname}`, {
75
+ const response = await relayFetch(`${config.relayUrl}${pathname}`, {
64
76
  ...init,
65
77
  headers: {
66
78
  ...init.headers,
67
79
  authorization: `Bearer ${config.controllerToken}`,
68
80
  },
69
- });
81
+ }, "Could not reach relay");
70
82
  if (!response.ok) {
71
83
  throw await relayHttpError(response, `${pathname} failed`);
72
84
  }
73
85
  return await readRelayJson(response, `${pathname} response is not valid JSON`);
74
86
  }
75
87
  export async function publicJsonRaw(config, pathname, init) {
76
- const response = await fetch(`${config.relayUrl}${pathname}`, init);
88
+ const response = await relayFetch(`${config.relayUrl}${pathname}`, init, "Could not reach relay");
77
89
  if (!response.ok) {
78
90
  throw await relayHttpError(response, `${pathname} failed`);
79
91
  }
@@ -0,0 +1,15 @@
1
+ import type { DecodedSessionEvent, SessionMetadata } from "./types.js";
2
+ import type { SessionSnapshot } from "../../shared/dist/index.js";
3
+ export declare function isAuthoritativeSessionModel(session: SessionSnapshot | undefined): boolean;
4
+ export declare function selectedSessionHistoryHead(session: SessionSnapshot): string;
5
+ export declare function selectedSessionProjectionHead(session: SessionSnapshot): string;
6
+ export declare function hasVisibleTranscriptPayload(event: DecodedSessionEvent): boolean;
7
+ export declare function isSelectedSessionHeadMaterialized(session: SessionSnapshot, events: readonly DecodedSessionEvent[]): boolean;
8
+ export declare function oldestEventIdForSession(events: readonly DecodedSessionEvent[], sessionId: string): number | undefined;
9
+ export declare function newestEventIdForSession(events: readonly DecodedSessionEvent[], sessionId: string): number;
10
+ export declare function sessionEventCount(events: readonly DecodedSessionEvent[], sessionId: string): number;
11
+ export declare function sessionHasHistoricalBackfillEvents(events: readonly DecodedSessionEvent[], sessionId: string): boolean;
12
+ export declare function sessionHasMissingHistoricalPrefix(session: SessionSnapshot | undefined, events: readonly DecodedSessionEvent[]): boolean;
13
+ export declare function runtimeHistoricalBackfillCanContinue(metadata: SessionMetadata | undefined, hasMissingHistoricalPrefix: boolean): boolean;
14
+ export declare function runtimeHistoricalBackfillKnown(metadata: SessionMetadata | undefined): boolean;
15
+ export declare function runtimeHistoricalBackfillCompleted(metadata: SessionMetadata | undefined): boolean;
@@ -0,0 +1,60 @@
1
+ import { oldestSessionEventId } from "./session-event-order.js";
2
+ export function isAuthoritativeSessionModel(session) {
3
+ return session?.historyModel === "authoritative-linear";
4
+ }
5
+ export function selectedSessionHistoryHead(session) {
6
+ return [session.currentHead ?? "", session.lastTurnId ?? "", session.status, String(session.updatedAt)].join(":");
7
+ }
8
+ export function selectedSessionProjectionHead(session) {
9
+ return [session.currentHead ?? "", session.lastTurnId ?? ""].join(":");
10
+ }
11
+ export function hasVisibleTranscriptPayload(event) {
12
+ const type = event.decoded?.type;
13
+ return type === "user_prompt" || type === "text_delta" || type === "tool_call";
14
+ }
15
+ export function isSelectedSessionHeadMaterialized(session, events) {
16
+ const currentHead = session.currentHead || session.lastTurnId;
17
+ if (!currentHead)
18
+ return true;
19
+ const localEvents = events.filter((event) => event.sessionId === session.id);
20
+ const eventIdMatch = /^event:(\d+)$/.exec(currentHead);
21
+ if (eventIdMatch)
22
+ return localEvents.some((event) => event.id === Number(eventIdMatch[1]));
23
+ return localEvents.some((event) => event.turnId === currentHead ||
24
+ (event.decoded?.type === "done" && (event.decoded.currentHead === currentHead || event.decoded.lastTurnId === currentHead)));
25
+ }
26
+ export function oldestEventIdForSession(events, sessionId) {
27
+ return oldestSessionEventId(events, sessionId);
28
+ }
29
+ export function newestEventIdForSession(events, sessionId) {
30
+ return Math.max(0, ...events.filter((event) => event.sessionId === sessionId).map((event) => event.id));
31
+ }
32
+ export function sessionEventCount(events, sessionId) {
33
+ return events.filter((event) => event.sessionId === sessionId).length;
34
+ }
35
+ export function sessionHasHistoricalBackfillEvents(events, sessionId) {
36
+ return events.some((event) => event.sessionId === sessionId && event.messageId?.startsWith("hist_"));
37
+ }
38
+ export function sessionHasMissingHistoricalPrefix(session, events) {
39
+ if (!session?.currentHead && !session?.lastTurnId)
40
+ return false;
41
+ const turnSeqs = events
42
+ .filter((event) => event.sessionId === session.id && event.messageId?.startsWith("hist_") && typeof event.turnSeq === "number")
43
+ .map((event) => event.turnSeq);
44
+ return turnSeqs.length > 0 && Math.min(...turnSeqs) > 0;
45
+ }
46
+ export function runtimeHistoricalBackfillCanContinue(metadata, hasMissingHistoricalPrefix) {
47
+ const status = metadata?.historicalBackfillStatus ?? metadata?.historicalBackfill;
48
+ return status === "partial" || metadata?.truncatedHistoricalBackfill === true || hasMissingHistoricalPrefix;
49
+ }
50
+ export function runtimeHistoricalBackfillKnown(metadata) {
51
+ return Boolean(metadata &&
52
+ (metadata.historicalBackfillStatus !== undefined ||
53
+ metadata.historicalBackfill !== undefined ||
54
+ metadata.historicalBackfillCursor !== undefined ||
55
+ metadata.truncatedHistoricalBackfill !== undefined));
56
+ }
57
+ export function runtimeHistoricalBackfillCompleted(metadata) {
58
+ const status = metadata?.historicalBackfillStatus ?? metadata?.historicalBackfill;
59
+ return status === "completed" || metadata?.historicalBackfillCursor === 0;
60
+ }
@@ -0,0 +1,8 @@
1
+ import type { ControllerLiveState } from "./live-types.js";
2
+ export declare function cloneLiveState(state: ControllerLiveState): ControllerLiveState;
3
+ export declare function upsertById<T extends {
4
+ id: string;
5
+ }>(items: readonly T[], next: T): T[];
6
+ export declare function sleep(ms: number): Promise<void>;
7
+ export declare function nonEmptyLiveString(value: string, label: string): string;
8
+ export declare function errorMessage(error: unknown): string;
@@ -0,0 +1,33 @@
1
+ import { ControllerClientError } from "./errors.js";
2
+ export function cloneLiveState(state) {
3
+ return {
4
+ ...state,
5
+ events: [...state.events],
6
+ machineMetadata: { ...state.machineMetadata },
7
+ machines: [...state.machines],
8
+ sessionMetadata: { ...state.sessionMetadata },
9
+ sessionPages: Object.fromEntries(Object.entries(state.sessionPages).map(([key, value]) => [key, { ...value }])),
10
+ sessions: [...state.sessions],
11
+ };
12
+ }
13
+ export function upsertById(items, next) {
14
+ const copy = [...items];
15
+ const index = copy.findIndex((item) => item.id === next.id);
16
+ if (index >= 0)
17
+ copy[index] = next;
18
+ else
19
+ copy.unshift(next);
20
+ return copy;
21
+ }
22
+ export function sleep(ms) {
23
+ return new Promise((resolve) => setTimeout(resolve, ms));
24
+ }
25
+ export function nonEmptyLiveString(value, label) {
26
+ const trimmed = value.trim();
27
+ if (!trimmed)
28
+ throw new ControllerClientError(`${label} must be a non-empty string`, "INVALID_ARGUMENT");
29
+ return trimmed;
30
+ }
31
+ export function errorMessage(error) {
32
+ return error instanceof Error ? error.message : String(error);
33
+ }