@p4code/cli 0.6.13 → 0.6.15

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/dist/bin.mjs CHANGED
@@ -8,7 +8,7 @@ import * as Effect from "effect/Effect";
8
8
  import * as Layer from "effect/Layer";
9
9
  import { Argument, Command, Flag, GlobalFlag, Prompt } from "effect/unstable/cli";
10
10
  import * as CliError from "effect/unstable/cli/CliError";
11
- import { FetchHttpClient, Headers as Headers$1, HttpBody, HttpClient, HttpClientError, HttpClientRequest, HttpClientResponse, HttpEffect, HttpRouter, HttpServer, HttpServerRequest, HttpServerRespondable, HttpServerResponse } from "effect/unstable/http";
11
+ import { FetchHttpClient, HttpBody, HttpClient, HttpClientError, HttpClientRequest, HttpClientResponse, HttpEffect, HttpRouter, HttpServer, HttpServerRequest, HttpServerRespondable, HttpServerResponse } from "effect/unstable/http";
12
12
  import * as Context from "effect/Context";
13
13
  import * as NodeOS from "node:os";
14
14
  import * as NodeNet from "node:net";
@@ -63,6 +63,8 @@ import * as NodeReadline from "node:readline";
63
63
  import * as NodeFSP from "node:fs/promises";
64
64
  import { access } from "node:fs/promises";
65
65
  import * as Clock from "effect/Clock";
66
+ import * as Headers$1 from "effect/unstable/http/Headers";
67
+ import * as HttpRouter$1 from "effect/unstable/http/HttpRouter";
66
68
  import * as NodeStream from "@effect/platform-node/NodeStream";
67
69
  import * as NodeHttp from "node:http";
68
70
  import * as Deferred from "effect/Deferred";
@@ -266,7 +268,7 @@ const make$124 = () => {
266
268
  const layer$124 = Layer.sync(NetService, make$124);
267
269
  //#endregion
268
270
  //#region package.json
269
- var version$1 = "0.6.13";
271
+ var version$1 = "0.6.15";
270
272
  //#endregion
271
273
  //#region src/config.ts
272
274
  /**
@@ -10163,7 +10165,8 @@ var DeviceOperationError = class extends Schema$1.TaggedErrorClass()("DeviceOper
10163
10165
  "request_failed",
10164
10166
  "invalid_payload",
10165
10167
  "settings_failed",
10166
- "hub_rejected"
10168
+ "hub_rejected",
10169
+ "agent_access_disabled"
10167
10170
  ]),
10168
10171
  exitCode: Schema$1.optional(Schema$1.Number),
10169
10172
  cause: Schema$1.Defect()
@@ -10174,7 +10177,8 @@ var DeviceOperationError = class extends Schema$1.TaggedErrorClass()("DeviceOper
10174
10177
  request_failed: "Could not communicate with device support. Try refreshing devices.",
10175
10178
  invalid_payload: "The device request could not be encoded.",
10176
10179
  settings_failed: "Could not read or save device settings.",
10177
- hub_rejected: "The device hub could not complete the request."
10180
+ hub_rejected: "The device hub could not complete the request.",
10181
+ agent_access_disabled: "Agent device access is off. Turn on Settings > Devices > Allow agents to control devices."
10178
10182
  }[this.reason];
10179
10183
  return `Device ${this.operation} failed: ${explanation}`;
10180
10184
  }
@@ -24244,6 +24248,49 @@ function isLoopbackHost(host) {
24244
24248
  return normalized === "localhost" || normalized === "127.0.0.1" || normalized === "::1" || normalized === "[::1]" || normalized.startsWith("127.");
24245
24249
  }
24246
24250
  //#endregion
24251
+ //#region src/httpCompression/HttpResponseCompression.ts
24252
+ const GZIP_MIN_BYTES = 1024;
24253
+ var HttpResponseCompression = class extends Context.Service()("@p4code/cli/httpCompression/HttpResponseCompression") {};
24254
+ const layerNode = Layer.effect(HttpResponseCompression, Effect.gen(function* () {
24255
+ const NodeZlib = yield* Effect.promise(() => import("node:zlib"));
24256
+ return { gzip: (body, options) => {
24257
+ const stream = NodeZlib.createGzip();
24258
+ stream.end(body);
24259
+ return HttpServerResponse$1.raw(stream, options);
24260
+ } };
24261
+ }));
24262
+ const layerBun = Layer.succeed(HttpResponseCompression, { gzip: (body, options) => HttpServerResponse$1.raw(new Response(body).body.pipeThrough(new CompressionStream("gzip")), options) });
24263
+ function acceptsGzip(value) {
24264
+ if (!value) return false;
24265
+ const accepted = new Map(value.split(",").map((entry) => {
24266
+ const [coding = "", ...parameters] = entry.trim().toLowerCase().split(";");
24267
+ const quality = parameters.map((parameter) => parameter.trim().match(/^q=(.+)$/)?.[1]).find((parameter) => parameter !== void 0);
24268
+ return [coding, quality === void 0 ? 1 : Number(quality)];
24269
+ }));
24270
+ return (accepted.get("gzip") ?? accepted.get("*") ?? 0) > 0;
24271
+ }
24272
+ function varyByAcceptEncoding(value) {
24273
+ if (!value) return "Accept-Encoding";
24274
+ const values = new Set(value.split(",").map((entry) => entry.trim().toLowerCase()));
24275
+ return values.has("*") || values.has("accept-encoding") ? value : `${value}, Accept-Encoding`;
24276
+ }
24277
+ const compressHttpResponse = Effect.fnUntraced(function* (response, acceptEncoding) {
24278
+ const body = response.body;
24279
+ if (body._tag !== "Uint8Array" || body.contentLength < GZIP_MIN_BYTES || !body.contentType.startsWith("application/json") || response.headers["content-encoding"]) return response;
24280
+ const variedResponse = HttpServerResponse$1.setHeader(response, "vary", varyByAcceptEncoding(response.headers.vary));
24281
+ if (!acceptsGzip(acceptEncoding)) return variedResponse;
24282
+ const compression = yield* HttpResponseCompression;
24283
+ const headers = Headers$1.set(Headers$1.remove(variedResponse.headers, "content-length"), "content-encoding", "gzip");
24284
+ return compression.gzip(body.body, {
24285
+ status: response.status,
24286
+ statusText: response.statusText,
24287
+ headers,
24288
+ cookies: response.cookies,
24289
+ contentType: body.contentType
24290
+ });
24291
+ });
24292
+ const httpCompressionLayer = HttpRouter$1.middleware((httpEffect) => Effect.flatMap(Effect.all([httpEffect, HttpServerRequest$1.HttpServerRequest]), ([response, request]) => compressHttpResponse(response, request.headers["accept-encoding"])), { global: true });
24293
+ //#endregion
24247
24294
  //#region src/persistence/Services/AgentAssets.ts
24248
24295
  var AgentAssetRepository = class extends Context.Service()("@p4code/cli/persistence/Services/AgentAssets/AgentAssetRepository") {};
24249
24296
  //#endregion
@@ -28795,6 +28842,321 @@ const hubTunnelClientSocketRoute = Effect.gen(function* () {
28795
28842
  })));
28796
28843
  const layer$96 = HttpRouter.add("GET", `${HUB_TUNNEL_CLIENT_SOCKET_PATH}/*`, hubTunnelClientSocketRoute);
28797
28844
  //#endregion
28845
+ //#region src/sync/HubLink.ts
28846
+ /**
28847
+ * Where this server's hub is, what it authenticates with, and whether it syncs
28848
+ * on its own.
28849
+ *
28850
+ * Absent by default. A server with no hub configured runs exactly as it does
28851
+ * today — the sync is skipped rather than failing, because "no hub" is a
28852
+ * complete and normal configuration, not a broken one.
28853
+ *
28854
+ * The record lives in `ServerSecretStore`, which already writes with
28855
+ * restrictive permissions, rather than in the settings file or the database.
28856
+ * The sync mode rides along in that record even though it is not a secret:
28857
+ * connect, disconnect and pause are then one write and one read, and the
28858
+ * settings panel reads one thing instead of joining two. Environment variables
28859
+ * override the link so a container or a service unit can be configured without
28860
+ * a first-run interactive step — the same trade the hub process itself makes
28861
+ * with `P4CODE_HUB_TOKEN_HASHES` — while the mode always comes from the stored
28862
+ * record, so a server configured by environment can still be paused.
28863
+ *
28864
+ * Mutable, deliberately. Resolving this once at layer construction would mean
28865
+ * connecting to a hub did nothing until the server restarted, which is a
28866
+ * miserable thing to discover after typing a token into a form.
28867
+ *
28868
+ * @module sync/HubLink
28869
+ */
28870
+ /** Secret name holding the hub record as JSON. */
28871
+ const HUB_LINK_SECRET_NAME = "hub-link";
28872
+ var HubLink = class extends Context.Service()("@p4code/cli/sync/HubLink") {};
28873
+ /** The submitted URL or token could not make a usable link. */
28874
+ var InvalidHubLink = class extends Schema$1.TaggedErrorClass()("InvalidHubLink", { detail: Schema$1.String }) {
28875
+ get message() {
28876
+ return this.detail;
28877
+ }
28878
+ };
28879
+ const trimTrailingSlash = (value) => value.replace(/\/+$/u, "");
28880
+ /**
28881
+ * Both halves must be present and the URL parseable. A half-configured link is
28882
+ * treated as no link at all, because a server that thinks it has a hub and
28883
+ * cannot form a request would report every sync as a failure rather than as the
28884
+ * "not configured" it actually is.
28885
+ */
28886
+ const validateHubLink = (rawBaseUrl, rawToken) => {
28887
+ const trimmed = trimTrailingSlash(rawBaseUrl.trim());
28888
+ const token = rawToken.trim();
28889
+ if (trimmed.length === 0 || token.length === 0) return Option.none();
28890
+ const baseUrl = /^[a-z][a-z0-9+.-]*:\/\//iu.test(trimmed) ? trimmed : `https://${trimmed}`;
28891
+ if (!URL.canParse(baseUrl)) return Option.none();
28892
+ const parsed = new URL(baseUrl);
28893
+ if (parsed.protocol !== "https:" && parsed.protocol !== "http:") return Option.none();
28894
+ return Option.some({
28895
+ baseUrl,
28896
+ token
28897
+ });
28898
+ };
28899
+ const StoredHubLink = Schema$1.fromJsonString(Schema$1.Struct({
28900
+ baseUrl: Schema$1.String,
28901
+ token: Schema$1.String,
28902
+ syncMode: Schema$1.optional(Schema$1.Literals(["auto", "paused"])),
28903
+ shareMode: Schema$1.optional(Schema$1.Literals(["all", "selected"]))
28904
+ }));
28905
+ const decodeStoredHubLink = Schema$1.decodeUnknownExit(StoredHubLink);
28906
+ const encodeStoredHubLink = Schema$1.encodeSync(StoredHubLink);
28907
+ /**
28908
+ * Parse a stored record, tolerating anything that is not the shape we wrote.
28909
+ *
28910
+ * A corrupt secret disables the sync rather than failing the server's start.
28911
+ * Losing sync is recoverable by reconnecting; refusing to boot over it is not
28912
+ * proportionate to what has gone wrong.
28913
+ */
28914
+ const parseHubLink = (raw) => {
28915
+ const decoded = decodeStoredHubLink(raw);
28916
+ if (decoded._tag !== "Success") return {
28917
+ settings: Option.none(),
28918
+ syncMode: "auto",
28919
+ shareMode: "all"
28920
+ };
28921
+ return {
28922
+ settings: validateHubLink(decoded.value.baseUrl, decoded.value.token),
28923
+ syncMode: decoded.value.syncMode ?? "auto",
28924
+ shareMode: decoded.value.shareMode ?? "all"
28925
+ };
28926
+ };
28927
+ const formatHubLink = (input) => encodeStoredHubLink({
28928
+ baseUrl: Option.match(input.settings, {
28929
+ onNone: () => "",
28930
+ onSome: (settings) => trimTrailingSlash(settings.baseUrl)
28931
+ }),
28932
+ token: Option.match(input.settings, {
28933
+ onNone: () => "",
28934
+ onSome: (s) => s.token
28935
+ }),
28936
+ syncMode: input.syncMode,
28937
+ shareMode: input.shareMode
28938
+ });
28939
+ const fromEnvironment = (environment) => validateHubLink(environment.P4CODE_HUB_URL ?? "", environment.P4CODE_HUB_TOKEN ?? "");
28940
+ const make$113 = Effect.fn("HubLink.make")(function* (environment) {
28941
+ const secrets = yield* ServerSecretStore;
28942
+ const env = environment ?? process.env;
28943
+ const fromEnv = fromEnvironment(env);
28944
+ const readStored = Effect.fn("HubLink.readStored")(function* () {
28945
+ const stored = yield* secrets.get(HUB_LINK_SECRET_NAME).pipe(Effect.orElseSucceed(() => Option.none()));
28946
+ return Option.match(stored, {
28947
+ onNone: () => ({
28948
+ settings: Option.none(),
28949
+ syncMode: "auto",
28950
+ shareMode: "all"
28951
+ }),
28952
+ onSome: (bytes) => parseHubLink(new TextDecoder().decode(bytes))
28953
+ });
28954
+ });
28955
+ const toState = (record) => Option.isSome(fromEnv) ? {
28956
+ settings: fromEnv,
28957
+ syncMode: record.syncMode,
28958
+ shareMode: record.shareMode,
28959
+ source: "environment"
28960
+ } : {
28961
+ settings: record.settings,
28962
+ syncMode: record.syncMode,
28963
+ shareMode: record.shareMode,
28964
+ source: Option.isSome(record.settings) ? "stored" : "none"
28965
+ };
28966
+ const cache = yield* Ref.make(yield* readStored());
28967
+ const updates = yield* PubSub.sliding({
28968
+ capacity: 1,
28969
+ replay: 1
28970
+ });
28971
+ yield* PubSub.publish(updates, toState(yield* Ref.get(cache)));
28972
+ const persist = Effect.fn("HubLink.persist")(function* (record) {
28973
+ yield* secrets.set(HUB_LINK_SECRET_NAME, new TextEncoder().encode(formatHubLink(record)));
28974
+ yield* Ref.set(cache, record);
28975
+ yield* PubSub.publish(updates, toState(record));
28976
+ return toState(record);
28977
+ });
28978
+ return {
28979
+ current: Ref.get(cache).pipe(Effect.map(toState)),
28980
+ changes: Stream.fromPubSub(updates),
28981
+ connect: (settings) => Effect.gen(function* () {
28982
+ const validated = validateHubLink(settings.baseUrl, settings.token);
28983
+ if (Option.isNone(validated)) return yield* new InvalidHubLink({ detail: "A hub needs an http or https URL and a non-empty token." });
28984
+ const record = yield* Ref.get(cache);
28985
+ return yield* persist({
28986
+ ...record,
28987
+ settings: validated
28988
+ });
28989
+ }),
28990
+ disconnect: Effect.gen(function* () {
28991
+ const record = yield* Ref.get(cache);
28992
+ return yield* persist({
28993
+ ...record,
28994
+ settings: Option.none()
28995
+ });
28996
+ }),
28997
+ setSyncMode: (mode) => Effect.gen(function* () {
28998
+ const record = yield* Ref.get(cache);
28999
+ return yield* persist({
29000
+ ...record,
29001
+ syncMode: mode
29002
+ });
29003
+ }),
29004
+ setShareMode: (mode) => Effect.gen(function* () {
29005
+ const record = yield* Ref.get(cache);
29006
+ return yield* persist({
29007
+ ...record,
29008
+ shareMode: mode
29009
+ });
29010
+ })
29011
+ };
29012
+ });
29013
+ const layer$95 = Layer.effect(HubLink, make$113());
29014
+ //#endregion
29015
+ //#region src/persistence/Layers/RemoteTasks.ts
29016
+ /**
29017
+ * The hub's task API, as a client.
29018
+ *
29019
+ * This used to be a `TaskRepository` in its own right, and the board was
29020
+ * either this or the local database. It is now the transport underneath
29021
+ * `SyncedTaskRepository`: local SQLite is a replica of the hub, so nothing
29022
+ * reads from here directly and the board no longer goes dark when the hub is
29023
+ * unreachable. What changed with it is the failure story — an unreachable hub
29024
+ * used to mean no board, and now means a board that is behind.
29025
+ *
29026
+ * The four calls here are what a mirror needs and no more: pull what changed
29027
+ * on the hub, push a whole row, arbitrate a claim, and delete. There is no partial
29028
+ * patch, because a mirror always holds the full row it wants the hub to have
29029
+ * and sending a diff of it would only add a way for the two to disagree.
29030
+ *
29031
+ * @module persistence/Layers/RemoteTasks
29032
+ */
29033
+ const HUB_REQUEST_TIMEOUT_MS$1 = 15e3;
29034
+ /**
29035
+ * How often the hub writes to an idle `/v1/task-changes` stream. The client
29036
+ * treats two missed beats as a dead connection. Under ten seconds because Bun
29037
+ * closes a connection idle for ten, and proxies in front of a hosted hub are
29038
+ * rarely more patient.
29039
+ */
29040
+ const HUB_TASK_CHANGES_HEARTBEAT_MS = 8e3;
29041
+ /**
29042
+ * `cursor`, `deletedTaskIds` and `full` are absent from a hub that predates
29043
+ * cursors, which answers every pull with the whole board.
29044
+ */
29045
+ const TaskListPage = Schema$1.Struct({
29046
+ tasks: Schema$1.Array(Task),
29047
+ deletedTaskIds: Schema$1.optional(Schema$1.Array(TaskId)),
29048
+ cursor: Schema$1.optional(Schema$1.String),
29049
+ full: Schema$1.optional(Schema$1.Boolean)
29050
+ });
29051
+ const ConflictBody$1 = Schema$1.Struct({ current: Task });
29052
+ const decodeTaskListPage = Schema$1.decodeUnknownEffect(TaskListPage);
29053
+ const decodeTask = Schema$1.decodeUnknownEffect(Task);
29054
+ const decodeConflictBody$1 = Schema$1.decodeUnknownEffect(ConflictBody$1);
29055
+ /**
29056
+ * Every failure here is reported as the persistence error type.
29057
+ *
29058
+ * The mirror catches these and carries on — a failed push leaves the row
29059
+ * `pending` and the next pass retries it — so what matters is that the detail
29060
+ * says the hub was the store that would not answer, which is what shows up in
29061
+ * the log when someone asks why their two machines disagree.
29062
+ */
29063
+ const hubUnavailable = (operation, detail) => new PersistenceSqlError({
29064
+ operation: `HubTaskClient.${operation}`,
29065
+ detail: `The hub board is unavailable: ${detail}`,
29066
+ cause: null
29067
+ });
29068
+ var HubTaskClient = class extends Context.Service()("@p4code/cli/persistence/Layers/RemoteTasks/HubTaskClient") {};
29069
+ const makeHubTaskClient = Effect.gen(function* () {
29070
+ const http = yield* HttpClient.HttpClient;
29071
+ const link = yield* HubLink;
29072
+ const isLinked = link.current.pipe(Effect.map((state) => Option.isSome(state.settings)));
29073
+ const requireSettings = (operation) => Effect.gen(function* () {
29074
+ const state = yield* link.current;
29075
+ if (Option.isNone(state.settings)) return yield* hubUnavailable(operation, "no hub is configured on this server");
29076
+ return state.settings.value;
29077
+ });
29078
+ const send = (operation, settings, request) => http.execute(request.pipe(HttpClientRequest.setHeader("authorization", `Bearer ${settings.token}`), HttpClientRequest.setHeader("accept", "application/json"))).pipe(Effect.timeout(HUB_REQUEST_TIMEOUT_MS$1), Effect.mapError((cause) => hubUnavailable(operation, `${cause._tag ?? "request failed"}`)));
29079
+ const pull = (from) => Effect.gen(function* () {
29080
+ const settings = yield* requireSettings("pull");
29081
+ const resume = from?.baseUrl === settings.baseUrl ? from : void 0;
29082
+ const request = HttpClientRequest.get(`${settings.baseUrl}/tasks${resume === void 0 ? "" : `?since=${encodeURIComponent(resume.cursor)}`}`);
29083
+ const response = yield* send("pull", settings, resume?.etag === void 0 ? request : HttpClientRequest.setHeader(request, "if-none-match", resume.etag));
29084
+ if (response.status === 304) return Option.none();
29085
+ if (response.status !== 200) return yield* hubUnavailable("pull", `status ${response.status}`);
29086
+ const body = yield* response.json.pipe(Effect.flatMap(decodeTaskListPage), Effect.mapError(() => hubUnavailable("pull", "the response did not match the contract")));
29087
+ return Option.some({
29088
+ full: body.full ?? true,
29089
+ tasks: body.tasks,
29090
+ deletedTaskIds: body.deletedTaskIds ?? [],
29091
+ next: body.cursor === void 0 ? void 0 : {
29092
+ baseUrl: settings.baseUrl,
29093
+ cursor: body.cursor,
29094
+ etag: response.headers.etag
29095
+ }
29096
+ });
29097
+ });
29098
+ const watch = Stream.unwrap(Effect.gen(function* () {
29099
+ const settings = yield* requireSettings("watch");
29100
+ const response = yield* http.execute(HttpClientRequest.get(`${settings.baseUrl}/v1/task-changes`).pipe(HttpClientRequest.setHeader("authorization", `Bearer ${settings.token}`))).pipe(Effect.mapError((cause) => hubUnavailable("watch", `${cause._tag ?? "request failed"}`)));
29101
+ if (response.status !== 200) return yield* hubUnavailable("watch", `status ${response.status}`);
29102
+ const relinked = link.changes.pipe(Stream.filter((state) => Option.isNone(state.settings) || state.settings.value.baseUrl !== settings.baseUrl || state.settings.value.token !== settings.token), Stream.runHead);
29103
+ return response.stream.pipe(Stream.mapError(() => hubUnavailable("watch", "the change stream failed")), Stream.decodeText, Stream.splitLines, Stream.timeout(HUB_TASK_CHANGES_HEARTBEAT_MS * 2), Stream.filter((line) => line.includes("\"ready\"") || line.includes("\"changed\"")), Stream.map(() => void 0), Stream.interruptWhen(relinked));
29104
+ }));
29105
+ const put = (row, readableIdPrefix) => Effect.gen(function* () {
29106
+ const settings = yield* requireSettings("put");
29107
+ const response = yield* send("put", settings, HttpClientRequest.bodyJsonUnsafe(HttpClientRequest.put(`${settings.baseUrl}/tasks/${encodeURIComponent(row.taskId)}`), {
29108
+ projectId: row.projectId,
29109
+ repositoryKey: row.repositoryKey,
29110
+ threadId: row.threadId,
29111
+ parentTaskId: row.parentTaskId,
29112
+ title: row.title,
29113
+ body: row.body,
29114
+ status: row.status,
29115
+ priority: row.priority,
29116
+ assignee: row.assignee,
29117
+ labels: row.labels,
29118
+ orderKey: row.orderKey,
29119
+ createdAt: row.createdAt,
29120
+ statusChangedAt: row.statusChangedAt ?? row.updatedAt,
29121
+ readableIdPrefix
29122
+ }));
29123
+ if (response.status !== 200 && response.status !== 201) return yield* hubUnavailable("put", `status ${response.status}`);
29124
+ return yield* response.json.pipe(Effect.flatMap(decodeTask), Effect.mapError(() => hubUnavailable("put", "the response did not match the contract")));
29125
+ });
29126
+ const patchIfStatus = (input) => Effect.gen(function* () {
29127
+ const settings = yield* requireSettings("patchIfStatus");
29128
+ const { taskId, ...fields } = input.patch;
29129
+ const response = yield* send("patchIfStatus", settings, HttpClientRequest.bodyJsonUnsafe(HttpClientRequest.patch(`${settings.baseUrl}/tasks/${encodeURIComponent(taskId)}`), {
29130
+ ...fields,
29131
+ expectedStatus: input.expectedStatus
29132
+ }));
29133
+ if (response.status === 404) return { outcome: "missing" };
29134
+ if (response.status === 409) return {
29135
+ outcome: "conflict",
29136
+ current: (yield* response.json.pipe(Effect.flatMap(decodeConflictBody$1), Effect.mapError(() => hubUnavailable("patchIfStatus", "the conflict response did not match the contract")))).current
29137
+ };
29138
+ if (response.status !== 200) return yield* hubUnavailable("patchIfStatus", `status ${response.status}`);
29139
+ return {
29140
+ outcome: "updated",
29141
+ task: yield* response.json.pipe(Effect.flatMap(decodeTask), Effect.mapError(() => hubUnavailable("patchIfStatus", "the response did not match the contract")))
29142
+ };
29143
+ });
29144
+ const remove = (taskId) => Effect.gen(function* () {
29145
+ const settings = yield* requireSettings("remove");
29146
+ const response = yield* send("remove", settings, HttpClientRequest.delete(`${settings.baseUrl}/tasks/${encodeURIComponent(taskId)}`));
29147
+ if (response.status !== 200 && response.status !== 204 && response.status !== 404) return yield* hubUnavailable("remove", `status ${response.status}`);
29148
+ });
29149
+ return {
29150
+ isLinked,
29151
+ pull,
29152
+ watch,
29153
+ put,
29154
+ patchIfStatus,
29155
+ remove
29156
+ };
29157
+ });
29158
+ Layer.effect(HubTaskClient, makeHubTaskClient);
29159
+ //#endregion
28798
29160
  //#region src/hub/HubHttpServer.ts
28799
29161
  /**
28800
29162
  * The hub's HTTP surface.
@@ -28857,10 +29219,66 @@ const identityRoute = HttpRouter.add("GET", "/v1/identity", respondToHubFailures
28857
29219
  environmentId: principal.environmentId
28858
29220
  }, { headers: { "cache-control": "no-store" } });
28859
29221
  })));
29222
+ /**
29223
+ * Minted per process, not per database: a database restored from a backup
29224
+ * carries its old sequence numbers, and a cursor past the restore point would
29225
+ * otherwise skip every write it lost. The cost is one full pull per server
29226
+ * after each hub restart.
29227
+ */
29228
+ const TASK_CURSOR_EPOCH = NodeCrypto.randomBytes(8).toString("hex");
29229
+ /**
29230
+ * The board, or what changed on it since a cursor.
29231
+ *
29232
+ * `?since=<cursor>` returns rows changed after that cursor plus the ids
29233
+ * deleted since, so a server polling an idle board downloads almost nothing.
29234
+ * An unfiltered list also carries the cursor to start from. A cursor this
29235
+ * process never issued gets the whole board back with `full: true`. Cursors
29236
+ * are `<epoch>-<seq>`; callers treat them as opaque.
29237
+ */
29238
+ const readTaskPage = Effect.fn("HubHttpServer.readTaskPage")(function* (filter, since) {
29239
+ const tasks = yield* TaskRepository;
29240
+ if (since === void 0 && Object.keys(filter).length > 0) return { tasks: yield* tasks.list(filter) };
29241
+ const sql = yield* SqlClient.SqlClient;
29242
+ const [head] = yield* sql`SELECT COALESCE(MAX(seq), 0) AS seq FROM task_change_log`;
29243
+ const seq = head?.seq ?? 0;
29244
+ const epoch = TASK_CURSOR_EPOCH;
29245
+ const cursor = `${epoch}-${seq}`;
29246
+ const [sinceEpoch, sinceSeqText] = since?.split("-") ?? [];
29247
+ const sinceSeq = Number(sinceSeqText);
29248
+ if (since === void 0 || sinceEpoch !== epoch || sinceSeq > seq) return {
29249
+ tasks: yield* tasks.list({}),
29250
+ deletedTaskIds: [],
29251
+ cursor,
29252
+ full: true
29253
+ };
29254
+ const changes = yield* sql`
29255
+ SELECT task_id AS "taskId", deleted FROM task_change_log
29256
+ WHERE seq > ${sinceSeq} AND seq <= ${seq}
29257
+ ORDER BY seq
29258
+ `;
29259
+ const changed = [];
29260
+ const deletedTaskIds = [];
29261
+ for (const change of changes) {
29262
+ if (change.deleted === 1) {
29263
+ deletedTaskIds.push(change.taskId);
29264
+ continue;
29265
+ }
29266
+ const task = yield* tasks.getById({ taskId: change.taskId });
29267
+ if (Option.isSome(task)) changed.push(task.value);
29268
+ }
29269
+ return {
29270
+ tasks: changed,
29271
+ deletedTaskIds,
29272
+ cursor,
29273
+ full: false
29274
+ };
29275
+ });
29276
+ /** Weak, because gzip may re-encode the same representation on the way out. */
29277
+ const taskPageEtag = (body) => `W/"${NodeCrypto.createHash("sha256").update(body).digest("base64url")}"`;
29278
+ const matchesEtag = (ifNoneMatch, etag) => ifNoneMatch !== void 0 && ifNoneMatch.split(",").map((candidate) => candidate.trim().replace(/^W\//, "")).some((candidate) => candidate === "*" || candidate === etag.replace(/^W\//, ""));
28860
29279
  const listTasksRoute = HttpRouter.add("GET", "/tasks", respondToHubFailures(Effect.gen(function* () {
28861
29280
  yield* authenticateHubRequest();
28862
29281
  const request = yield* HttpServerRequest.HttpServerRequest;
28863
- const tasks = yield* TaskRepository;
28864
29282
  const url = new URL(request.url, "http://hub.invalid");
28865
29283
  const filter = yield* decodeOrInvalid(TaskListFilter, "Query string does not match TaskListFilter.")(Object.fromEntries([
28866
29284
  "projectId",
@@ -28869,8 +29287,39 @@ const listTasksRoute = HttpRouter.add("GET", "/tasks", respondToHubFailures(Effe
28869
29287
  "assignee",
28870
29288
  "query"
28871
29289
  ].map((key) => [key, url.searchParams.get(key)]).filter(([, value]) => value !== null)));
28872
- const listed = yield* tasks.list(filter);
28873
- return HttpServerResponse.jsonUnsafe({ tasks: listed });
29290
+ const sinceParam = url.searchParams.get("since");
29291
+ if (sinceParam !== null && (!/^[0-9a-f]{1,32}-\d{1,15}$/.test(sinceParam) || Object.keys(filter).length > 0)) return yield* new InvalidHubRequest({ detail: "`since` must be a cursor from an earlier page and cannot be combined with filters." });
29292
+ const page = yield* readTaskPage(filter, sinceParam ?? void 0);
29293
+ const body = JSON.stringify(page);
29294
+ const etag = taskPageEtag(body);
29295
+ if (matchesEtag(request.headers["if-none-match"], etag)) return HttpServerResponse.empty({
29296
+ status: 304,
29297
+ headers: { etag }
29298
+ });
29299
+ return HttpServerResponse.text(body, {
29300
+ contentType: "application/json",
29301
+ headers: { etag }
29302
+ });
29303
+ })));
29304
+ const taskChangeLine = (type) => new TextEncoder().encode(`{"type":"${type}"}\n`);
29305
+ /**
29306
+ * A nudge per board write, so linked servers pull when something changed
29307
+ * instead of on a timer.
29308
+ *
29309
+ * Carries no rows: the server answers a nudge with the same cursor pull it
29310
+ * always makes, so a nudge that is lost or duplicated costs nothing but
29311
+ * latency. `ready` doubles as a nudge, covering whatever changed while the
29312
+ * server was not listening.
29313
+ */
29314
+ const taskChangesRoute = HttpRouter.add("GET", "/v1/task-changes", respondToHubFailures(Effect.gen(function* () {
29315
+ yield* authenticateHubRequest();
29316
+ const tasks = yield* TaskRepository;
29317
+ const events = Stream.merge(tasks.streamWithSnapshot.pipe(Stream.map((event) => taskChangeLine(event.type === "snapshot" ? "ready" : "changed"))), Stream.tick(HUB_TASK_CHANGES_HEARTBEAT_MS).pipe(Stream.drop(1), Stream.map(() => taskChangeLine("heartbeat"))));
29318
+ return HttpServerResponse.stream(events, { headers: {
29319
+ "content-type": "application/x-ndjson",
29320
+ "cache-control": "no-store",
29321
+ "x-accel-buffering": "no"
29322
+ } });
28874
29323
  })));
28875
29324
  const getTaskRoute = HttpRouter.add("GET", "/tasks/:taskId", respondToHubFailures(Effect.gen(function* () {
28876
29325
  yield* authenticateHubRequest();
@@ -29292,7 +29741,7 @@ const releaseFeedLeaseRoute = HttpRouter.add("DELETE", "/feed/lease", respondToH
29292
29741
  })));
29293
29742
  /** Provide around HttpRouter.serve so request fibers share these process-owned services. */
29294
29743
  const routingServices = Layer.mergeAll(layer$113, layer$111, layer$102, layer$101).pipe(Layer.provideMerge(layer$112));
29295
- const layer$95 = Layer.mergeAll(layer$100, layer$99, layer$103, layer$97, layer$96, layer$110, layer$98, healthRoute, identityRoute, listTasksRoute, getTaskRoute, createTaskRoute, putTaskRoute, updateTaskRoute, deleteTaskRoute, listAssetsRoute, getAssetRoute, putAssetRoute, deleteAssetRoute, listFeedSourcesRoute, upsertFeedSourceRoute, deleteFeedSourceRoute, listFeedRoute, putFeedArticleRoute, putFeedEngagementRoute, markFeedReadRoute, cleanupFeedRoute, setFeedRefreshedAtRoute, acquireFeedLeaseRoute, renewFeedLeaseRoute, releaseFeedLeaseRoute).pipe(Layer.provideMerge(layer$113), Layer.provideMerge(layer$111), Layer.provideMerge(layer$102));
29744
+ const layer$94 = Layer.mergeAll(layer$100, layer$99, layer$103, layer$97, layer$96, layer$110, layer$98, healthRoute, identityRoute, listTasksRoute, taskChangesRoute, getTaskRoute, createTaskRoute, putTaskRoute, updateTaskRoute, deleteTaskRoute, listAssetsRoute, getAssetRoute, putAssetRoute, deleteAssetRoute, listFeedSourcesRoute, upsertFeedSourceRoute, deleteFeedSourceRoute, listFeedRoute, putFeedArticleRoute, putFeedEngagementRoute, markFeedReadRoute, cleanupFeedRoute, setFeedRefreshedAtRoute, acquireFeedLeaseRoute, renewFeedLeaseRoute, releaseFeedLeaseRoute).pipe(Layer.provideMerge(layer$113), Layer.provideMerge(layer$111), Layer.provideMerge(layer$102));
29296
29745
  //#endregion
29297
29746
  //#region src/hub/Migrations/001_Tasks.ts
29298
29747
  /**
@@ -29789,6 +30238,57 @@ var _022_AnalyticsFacts_default = Effect.gen(function* () {
29789
30238
  ON hub_analytics_facts(account_id, kind, occurred_at DESC, event_id DESC)`;
29790
30239
  });
29791
30240
  //#endregion
30241
+ //#region src/hub/Migrations/023_TaskChangeLog.ts
30242
+ /**
30243
+ * One row per task that has ever existed, stamped with the order of its last
30244
+ * change, so a server can ask for "everything since cursor N" instead of the
30245
+ * whole board every poll.
30246
+ *
30247
+ * The sequence is the hub's, not `updated_at`: that timestamp is written by
30248
+ * whichever machine made the edit, and a machine with a slow clock would land
30249
+ * behind a cursor that already moved past it. `AUTOINCREMENT` never reuses a
30250
+ * value, and SQLite serializes writers, so a reader never sees N+1 before N.
30251
+ *
30252
+ * Triggers rather than repository code, because the hub shares
30253
+ * `TaskRepository` with every environment server and only the hub needs this.
30254
+ * A delete keeps its row with `deleted = 1`, which is how another machine
30255
+ * learns the task is gone. The triggers delete then insert rather than
30256
+ * `INSERT OR REPLACE`, because the repository writes with an upsert and its
30257
+ * conflict policy overrides the trigger's, failing the whole write.
30258
+ */
30259
+ var _023_TaskChangeLog_default = Effect.gen(function* () {
30260
+ const sql = yield* SqlClient.SqlClient;
30261
+ yield* sql`
30262
+ CREATE TABLE task_change_log (
30263
+ seq INTEGER PRIMARY KEY AUTOINCREMENT,
30264
+ task_id TEXT NOT NULL UNIQUE,
30265
+ deleted INTEGER NOT NULL DEFAULT 0
30266
+ )
30267
+ `;
30268
+ yield* sql`
30269
+ INSERT INTO task_change_log (task_id)
30270
+ SELECT task_id FROM tasks ORDER BY updated_at, task_id
30271
+ `;
30272
+ yield* sql`
30273
+ CREATE TRIGGER task_change_log_insert AFTER INSERT ON tasks BEGIN
30274
+ DELETE FROM task_change_log WHERE task_id = NEW.task_id;
30275
+ INSERT INTO task_change_log (task_id, deleted) VALUES (NEW.task_id, 0);
30276
+ END
30277
+ `;
30278
+ yield* sql`
30279
+ CREATE TRIGGER task_change_log_update AFTER UPDATE ON tasks BEGIN
30280
+ DELETE FROM task_change_log WHERE task_id = NEW.task_id;
30281
+ INSERT INTO task_change_log (task_id, deleted) VALUES (NEW.task_id, 0);
30282
+ END
30283
+ `;
30284
+ yield* sql`
30285
+ CREATE TRIGGER task_change_log_delete AFTER DELETE ON tasks BEGIN
30286
+ DELETE FROM task_change_log WHERE task_id = OLD.task_id;
30287
+ INSERT INTO task_change_log (task_id, deleted) VALUES (OLD.task_id, 1);
30288
+ END
30289
+ `;
30290
+ });
30291
+ //#endregion
29792
30292
  //#region src/hub/Migrations.ts
29793
30293
  /**
29794
30294
  * Hub migrations.
@@ -29909,6 +30409,11 @@ const hubMigrationEntries = [
29909
30409
  22,
29910
30410
  "AnalyticsFacts",
29911
30411
  _022_AnalyticsFacts_default
30412
+ ],
30413
+ [
30414
+ 23,
30415
+ "TaskChangeLog",
30416
+ _023_TaskChangeLog_default
29912
30417
  ]
29913
30418
  ];
29914
30419
  const hubMigrationLoader = Migrator.fromRecord(Object.fromEntries(hubMigrationEntries.map(([id, name, migration]) => [`${id}_${name}`, migration])));
@@ -30000,7 +30505,7 @@ const announce = Layer.effectDiscard(Effect.gen(function* () {
30000
30505
  });
30001
30506
  if (isRemoteReachableHost(config.host)) yield* Effect.logWarning("Hub is reachable beyond loopback. The bearer token is the only control — repeated auth failures are not rate limited.", { host: config.host });
30002
30507
  }));
30003
- const makeHubLayer = HttpRouter.serve(Layer.mergeAll(layer$95, layer$107, layer$109)).pipe(Layer.provideMerge(routingServices), Layer.provideMerge(layer$105), Layer.provideMerge(layer$106), Layer.provideMerge(layer$104), Layer.provideMerge(announce), Layer.provideMerge(HubHttpServerLive), Layer.provideMerge(layer$115), Layer.provideMerge(TaskRepositoryLive), Layer.provideMerge(AgentAssetRepositoryLive), Layer.provideMerge(HubPersistenceLive));
30508
+ const makeHubLayer = HttpRouter.serve(Layer.mergeAll(layer$94, layer$107, layer$109).pipe(Layer.provide(httpCompressionLayer))).pipe(Layer.provideMerge(typeof Bun !== "undefined" ? layerBun : layerNode), Layer.provideMerge(routingServices), Layer.provideMerge(layer$105), Layer.provideMerge(layer$106), Layer.provideMerge(layer$104), Layer.provideMerge(announce), Layer.provideMerge(HubHttpServerLive), Layer.provideMerge(layer$115), Layer.provideMerge(TaskRepositoryLive), Layer.provideMerge(AgentAssetRepositoryLive), Layer.provideMerge(HubPersistenceLive));
30004
30509
  const runHub = Layer.launch(makeHubLayer);
30005
30510
  //#endregion
30006
30511
  //#region src/atomicWrite.ts
@@ -30157,7 +30662,7 @@ function stripDefaultServerSettings(current, defaults) {
30157
30662
  }
30158
30663
  return Object.is(current, defaults) ? void 0 : current;
30159
30664
  }
30160
- const make$113 = Effect.gen(function* () {
30665
+ const make$112 = Effect.gen(function* () {
30161
30666
  const { settingsPath } = yield* ServerConfig$1;
30162
30667
  const fs = yield* FileSystem.FileSystem;
30163
30668
  const pathService = yield* Path$1.Path;
@@ -30380,7 +30885,7 @@ const make$113 = Effect.gen(function* () {
30380
30885
  }
30381
30886
  };
30382
30887
  });
30383
- const layer$94 = Layer.effect(ServerSettingsService, make$113);
30888
+ const layer$93 = Layer.effect(ServerSettingsService, make$112);
30384
30889
  //#endregion
30385
30890
  //#region src/provider/skillWorkspace.ts
30386
30891
  /** Ancestors inside the nearest Git checkout, ordered from root to cwd. */
@@ -31124,7 +31629,7 @@ function entriesOf(config) {
31124
31629
  return found;
31125
31630
  }
31126
31631
  var ClaudeMcpFiles = class extends Context.Service()("@p4code/cli/mcp/ClaudeMcpFiles") {};
31127
- const make$112 = Effect.gen(function* () {
31632
+ const make$111 = Effect.gen(function* () {
31128
31633
  const fileSystem = yield* FileSystem.FileSystem;
31129
31634
  const path = yield* Path$1.Path;
31130
31635
  const services = yield* Effect.context();
@@ -31195,7 +31700,7 @@ const make$112 = Effect.gen(function* () {
31195
31700
  removeProject: (projectDir, name) => removeAt(Effect.succeed(projectFile(projectDir)))(name)
31196
31701
  };
31197
31702
  });
31198
- const layer$93 = Layer.effect(ClaudeMcpFiles, make$112);
31703
+ const layer$92 = Layer.effect(ClaudeMcpFiles, make$111);
31199
31704
  Layer.succeed(ClaudeMcpFiles, {
31200
31705
  readUser: Effect.succeed([]),
31201
31706
  readUserStrict: Effect.succeed([]),
@@ -31369,7 +31874,7 @@ const decodeClientRegistration = Schema$1.decodeUnknownExit(ClientRegistrationRe
31369
31874
  const decodeTokenResponse = Schema$1.decodeUnknownExit(TokenResponse);
31370
31875
  var McpOAuth = class extends Context.Service()("@p4code/cli/mcp/McpOAuth") {};
31371
31876
  const registryError = (detail) => new McpRegistryError({ detail });
31372
- const make$111 = Effect.gen(function* () {
31877
+ const make$110 = Effect.gen(function* () {
31373
31878
  const config = yield* ServerConfig$1;
31374
31879
  const secrets = yield* ServerSecretStore;
31375
31880
  const http = yield* HttpClient.HttpClient;
@@ -31774,7 +32279,7 @@ const make$111 = Effect.gen(function* () {
31774
32279
  grantIdentityFor
31775
32280
  };
31776
32281
  });
31777
- const layer$92 = Layer.effect(McpOAuth, make$111);
32282
+ const layer$91 = Layer.effect(McpOAuth, make$110);
31778
32283
  Layer.succeed(McpOAuth, {
31779
32284
  statusFor: () => Effect.succeed(Option.none()),
31780
32285
  begin: () => Effect.fail(new McpRegistryError({ detail: "OAuth sign-in is not available." })),
@@ -31829,7 +32334,7 @@ const slotsOf = (registration) => registration.secrets ?? [];
31829
32334
  */
31830
32335
  const OMITTED_FOR_SIGN_IN = "mcp server omitted: sign-in required";
31831
32336
  const SIGN_IN_ACTION = "Connect it in Settings > MCP servers.";
31832
- const make$110 = Effect.gen(function* () {
32337
+ const make$109 = Effect.gen(function* () {
31833
32338
  const config = yield* ServerConfig$1;
31834
32339
  const secrets = yield* ServerSecretStore;
31835
32340
  const oauth = yield* McpOAuth;
@@ -32109,7 +32614,7 @@ const make$110 = Effect.gen(function* () {
32109
32614
  resolveForSessionAtClaudeUserConfigPath
32110
32615
  };
32111
32616
  });
32112
- const layer$91 = Layer.effect(McpRegistry, make$110);
32617
+ const layer$90 = Layer.effect(McpRegistry, make$109);
32113
32618
  //#endregion
32114
32619
  //#region src/sync/skillDirectory.ts
32115
32620
  /**
@@ -32393,176 +32898,6 @@ const removeAgentDefinition = Effect.fn("agentDefinitionFiles.remove")(function*
32393
32898
  yield* fileSystem.remove(path.join(input.agentsRoot, input.name), { force: true });
32394
32899
  });
32395
32900
  //#endregion
32396
- //#region src/sync/HubLink.ts
32397
- /**
32398
- * Where this server's hub is, what it authenticates with, and whether it syncs
32399
- * on its own.
32400
- *
32401
- * Absent by default. A server with no hub configured runs exactly as it does
32402
- * today — the sync is skipped rather than failing, because "no hub" is a
32403
- * complete and normal configuration, not a broken one.
32404
- *
32405
- * The record lives in `ServerSecretStore`, which already writes with
32406
- * restrictive permissions, rather than in the settings file or the database.
32407
- * The sync mode rides along in that record even though it is not a secret:
32408
- * connect, disconnect and pause are then one write and one read, and the
32409
- * settings panel reads one thing instead of joining two. Environment variables
32410
- * override the link so a container or a service unit can be configured without
32411
- * a first-run interactive step — the same trade the hub process itself makes
32412
- * with `P4CODE_HUB_TOKEN_HASHES` — while the mode always comes from the stored
32413
- * record, so a server configured by environment can still be paused.
32414
- *
32415
- * Mutable, deliberately. Resolving this once at layer construction would mean
32416
- * connecting to a hub did nothing until the server restarted, which is a
32417
- * miserable thing to discover after typing a token into a form.
32418
- *
32419
- * @module sync/HubLink
32420
- */
32421
- /** Secret name holding the hub record as JSON. */
32422
- const HUB_LINK_SECRET_NAME = "hub-link";
32423
- var HubLink = class extends Context.Service()("@p4code/cli/sync/HubLink") {};
32424
- /** The submitted URL or token could not make a usable link. */
32425
- var InvalidHubLink = class extends Schema$1.TaggedErrorClass()("InvalidHubLink", { detail: Schema$1.String }) {
32426
- get message() {
32427
- return this.detail;
32428
- }
32429
- };
32430
- const trimTrailingSlash = (value) => value.replace(/\/+$/u, "");
32431
- /**
32432
- * Both halves must be present and the URL parseable. A half-configured link is
32433
- * treated as no link at all, because a server that thinks it has a hub and
32434
- * cannot form a request would report every sync as a failure rather than as the
32435
- * "not configured" it actually is.
32436
- */
32437
- const validateHubLink = (rawBaseUrl, rawToken) => {
32438
- const trimmed = trimTrailingSlash(rawBaseUrl.trim());
32439
- const token = rawToken.trim();
32440
- if (trimmed.length === 0 || token.length === 0) return Option.none();
32441
- const baseUrl = /^[a-z][a-z0-9+.-]*:\/\//iu.test(trimmed) ? trimmed : `https://${trimmed}`;
32442
- if (!URL.canParse(baseUrl)) return Option.none();
32443
- const parsed = new URL(baseUrl);
32444
- if (parsed.protocol !== "https:" && parsed.protocol !== "http:") return Option.none();
32445
- return Option.some({
32446
- baseUrl,
32447
- token
32448
- });
32449
- };
32450
- const StoredHubLink = Schema$1.fromJsonString(Schema$1.Struct({
32451
- baseUrl: Schema$1.String,
32452
- token: Schema$1.String,
32453
- syncMode: Schema$1.optional(Schema$1.Literals(["auto", "paused"])),
32454
- shareMode: Schema$1.optional(Schema$1.Literals(["all", "selected"]))
32455
- }));
32456
- const decodeStoredHubLink = Schema$1.decodeUnknownExit(StoredHubLink);
32457
- const encodeStoredHubLink = Schema$1.encodeSync(StoredHubLink);
32458
- /**
32459
- * Parse a stored record, tolerating anything that is not the shape we wrote.
32460
- *
32461
- * A corrupt secret disables the sync rather than failing the server's start.
32462
- * Losing sync is recoverable by reconnecting; refusing to boot over it is not
32463
- * proportionate to what has gone wrong.
32464
- */
32465
- const parseHubLink = (raw) => {
32466
- const decoded = decodeStoredHubLink(raw);
32467
- if (decoded._tag !== "Success") return {
32468
- settings: Option.none(),
32469
- syncMode: "auto",
32470
- shareMode: "all"
32471
- };
32472
- return {
32473
- settings: validateHubLink(decoded.value.baseUrl, decoded.value.token),
32474
- syncMode: decoded.value.syncMode ?? "auto",
32475
- shareMode: decoded.value.shareMode ?? "all"
32476
- };
32477
- };
32478
- const formatHubLink = (input) => encodeStoredHubLink({
32479
- baseUrl: Option.match(input.settings, {
32480
- onNone: () => "",
32481
- onSome: (settings) => trimTrailingSlash(settings.baseUrl)
32482
- }),
32483
- token: Option.match(input.settings, {
32484
- onNone: () => "",
32485
- onSome: (s) => s.token
32486
- }),
32487
- syncMode: input.syncMode,
32488
- shareMode: input.shareMode
32489
- });
32490
- const fromEnvironment = (environment) => validateHubLink(environment.P4CODE_HUB_URL ?? "", environment.P4CODE_HUB_TOKEN ?? "");
32491
- const make$109 = Effect.fn("HubLink.make")(function* (environment) {
32492
- const secrets = yield* ServerSecretStore;
32493
- const env = environment ?? process.env;
32494
- const fromEnv = fromEnvironment(env);
32495
- const readStored = Effect.fn("HubLink.readStored")(function* () {
32496
- const stored = yield* secrets.get(HUB_LINK_SECRET_NAME).pipe(Effect.orElseSucceed(() => Option.none()));
32497
- return Option.match(stored, {
32498
- onNone: () => ({
32499
- settings: Option.none(),
32500
- syncMode: "auto",
32501
- shareMode: "all"
32502
- }),
32503
- onSome: (bytes) => parseHubLink(new TextDecoder().decode(bytes))
32504
- });
32505
- });
32506
- const toState = (record) => Option.isSome(fromEnv) ? {
32507
- settings: fromEnv,
32508
- syncMode: record.syncMode,
32509
- shareMode: record.shareMode,
32510
- source: "environment"
32511
- } : {
32512
- settings: record.settings,
32513
- syncMode: record.syncMode,
32514
- shareMode: record.shareMode,
32515
- source: Option.isSome(record.settings) ? "stored" : "none"
32516
- };
32517
- const cache = yield* Ref.make(yield* readStored());
32518
- const updates = yield* PubSub.sliding({
32519
- capacity: 1,
32520
- replay: 1
32521
- });
32522
- yield* PubSub.publish(updates, toState(yield* Ref.get(cache)));
32523
- const persist = Effect.fn("HubLink.persist")(function* (record) {
32524
- yield* secrets.set(HUB_LINK_SECRET_NAME, new TextEncoder().encode(formatHubLink(record)));
32525
- yield* Ref.set(cache, record);
32526
- yield* PubSub.publish(updates, toState(record));
32527
- return toState(record);
32528
- });
32529
- return {
32530
- current: Ref.get(cache).pipe(Effect.map(toState)),
32531
- changes: Stream.fromPubSub(updates),
32532
- connect: (settings) => Effect.gen(function* () {
32533
- const validated = validateHubLink(settings.baseUrl, settings.token);
32534
- if (Option.isNone(validated)) return yield* new InvalidHubLink({ detail: "A hub needs an http or https URL and a non-empty token." });
32535
- const record = yield* Ref.get(cache);
32536
- return yield* persist({
32537
- ...record,
32538
- settings: validated
32539
- });
32540
- }),
32541
- disconnect: Effect.gen(function* () {
32542
- const record = yield* Ref.get(cache);
32543
- return yield* persist({
32544
- ...record,
32545
- settings: Option.none()
32546
- });
32547
- }),
32548
- setSyncMode: (mode) => Effect.gen(function* () {
32549
- const record = yield* Ref.get(cache);
32550
- return yield* persist({
32551
- ...record,
32552
- syncMode: mode
32553
- });
32554
- }),
32555
- setShareMode: (mode) => Effect.gen(function* () {
32556
- const record = yield* Ref.get(cache);
32557
- return yield* persist({
32558
- ...record,
32559
- shareMode: mode
32560
- });
32561
- })
32562
- };
32563
- });
32564
- const layer$90 = Layer.effect(HubLink, make$109());
32565
- //#endregion
32566
32901
  //#region src/sync/HubAssetClient.ts
32567
32902
  /**
32568
32903
  * The server's half of the hub's asset API.
@@ -32576,7 +32911,7 @@ const layer$90 = Layer.effect(HubLink, make$109());
32576
32911
  *
32577
32912
  * @module sync/HubAssetClient
32578
32913
  */
32579
- const HUB_REQUEST_TIMEOUT_MS$1 = 15e3;
32914
+ const HUB_REQUEST_TIMEOUT_MS = 15e3;
32580
32915
  /**
32581
32916
  * The hub could not be reached, or answered something unusable.
32582
32917
  *
@@ -32589,9 +32924,9 @@ var HubAssetConflict = class extends Data.TaggedError("HubAssetConflict") {};
32589
32924
  /** No hub is configured on this server, so there is nothing to talk to. */
32590
32925
  var HubNotConfigured = class extends Data.TaggedError("HubNotConfigured") {};
32591
32926
  const AssetListPage = Schema$1.Struct({ assets: Schema$1.Array(AgentAsset) });
32592
- const ConflictBody$1 = Schema$1.Struct({ current: Schema$1.NullOr(AgentAsset) });
32927
+ const ConflictBody = Schema$1.Struct({ current: Schema$1.NullOr(AgentAsset) });
32593
32928
  const decodeAssetListPage = Schema$1.decodeUnknownEffect(AssetListPage);
32594
- const decodeConflictBody$1 = Schema$1.decodeUnknownEffect(ConflictBody$1);
32929
+ const decodeConflictBody = Schema$1.decodeUnknownEffect(ConflictBody);
32595
32930
  const decodeAsset = Schema$1.decodeUnknownEffect(AgentAsset);
32596
32931
  var HubAssetClient = class extends Context.Service()("@p4code/cli/sync/HubAssetClient") {};
32597
32932
  const make$108 = Effect.gen(function* () {
@@ -32603,7 +32938,7 @@ const make$108 = Effect.gen(function* () {
32603
32938
  return state.settings.value;
32604
32939
  });
32605
32940
  const send = Effect.fn("HubAssetClient.send")(function* (operation, settings, request) {
32606
- return yield* http.execute(request.pipe(HttpClientRequest.setHeader("authorization", `Bearer ${settings.token}`), HttpClientRequest.setHeader("accept", "application/json"))).pipe(Effect.timeout(HUB_REQUEST_TIMEOUT_MS$1), Effect.mapError((cause) => new HubUnavailable({
32941
+ return yield* http.execute(request.pipe(HttpClientRequest.setHeader("authorization", `Bearer ${settings.token}`), HttpClientRequest.setHeader("accept", "application/json"))).pipe(Effect.timeout(HUB_REQUEST_TIMEOUT_MS), Effect.mapError((cause) => new HubUnavailable({
32607
32942
  operation,
32608
32943
  detail: `${cause._tag ?? "error"}`
32609
32944
  })));
@@ -32623,7 +32958,7 @@ const make$108 = Effect.gen(function* () {
32623
32958
  * which is what lets a caller show both sides instead of only saying no.
32624
32959
  */
32625
32960
  const conflictFrom = Effect.fn("HubAssetClient.conflictFrom")(function* (name, response) {
32626
- const body = yield* decodeBody("conflict", decodeConflictBody$1, response).pipe(Effect.orElseSucceed(() => ({ current: null })));
32961
+ const body = yield* decodeBody("conflict", decodeConflictBody, response).pipe(Effect.orElseSucceed(() => ({ current: null })));
32627
32962
  return new HubAssetConflict({
32628
32963
  name,
32629
32964
  current: body.current === null ? Option.none() : Option.some(body.current)
@@ -34849,7 +35184,7 @@ const findAgentPreset = (requested) => {
34849
35184
  };
34850
35185
  //#endregion
34851
35186
  //#region src/cli/assets.ts
34852
- const SyncServicesLive = layer$88.pipe(Layer.provideMerge(layer$89), Layer.provideMerge(layer$90), Layer.provideMerge(layer$94.pipe(Layer.provide(layer$120))), Layer.provideMerge(layer$120), Layer.provideMerge(FetchHttpClient.layer));
35187
+ const SyncServicesLive = layer$88.pipe(Layer.provideMerge(layer$89), Layer.provideMerge(layer$95), Layer.provideMerge(layer$93.pipe(Layer.provide(layer$120))), Layer.provideMerge(layer$120), Layer.provideMerge(FetchHttpClient.layer));
34853
35188
  const runWithSync = (flags, run) => Effect.gen(function* () {
34854
35189
  const config = yield* resolveCliAuthConfig(flags, yield* GlobalFlag.LogLevel);
34855
35190
  return yield* Effect.gen(function* () {
@@ -51250,7 +51585,7 @@ const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function* (input)
51250
51585
  yield* (yield* initialize).refresh;
51251
51586
  if (access === "agent" && !state.agentAccessEnabled) return yield* new DeviceOperationError({
51252
51587
  operation: "list",
51253
- reason: "settings_failed",
51588
+ reason: "agent_access_disabled",
51254
51589
  cause: "Agent device access is disabled."
51255
51590
  });
51256
51591
  const ids = enabled ? state.hosts.map((host) => host.id) : [];
@@ -57639,6 +57974,7 @@ const requireThreadSpawn = Effect.fn("mcp.requireThreadSpawn")(function* (fusion
57639
57974
  //#region src/mcp/DeviceInvocation.ts
57640
57975
  const isDeviceToolUnavailableError = Schema$1.is(DeviceToolUnavailableError);
57641
57976
  const isDeviceActionUnavailableError = Schema$1.is(DeviceActionUnavailableError);
57977
+ const isDeviceOperationError = Schema$1.is(DeviceOperationError);
57642
57978
  /** Internal bridge; tool payloads never choose thread identity or daemon credentials. */
57643
57979
  const withDeviceInvocation = (execute, capability = "devices") => Effect.gen(function* () {
57644
57980
  const invocation = yield* McpInvocationContext;
@@ -57646,7 +57982,7 @@ const withDeviceInvocation = (execute, capability = "devices") => Effect.gen(fun
57646
57982
  const lifetime = invocation.authorizationLifetime;
57647
57983
  const denied = () => new DeviceToolUnavailableError({ reason: "Device command unavailable or authorization changed." });
57648
57984
  if (!invocation.capabilities.has(capability) || !lifetime?.revocation || lifetime.revoked) return yield* denied();
57649
- const work = execute(service, invocation.threadId).pipe(Effect.tap(() => lifetime.revoked ? Effect.fail(denied()) : Effect.void), Effect.mapError((error) => isDeviceToolUnavailableError(error) || isDeviceActionUnavailableError(error) ? error : denied()));
57985
+ const work = execute(service, invocation.threadId).pipe(Effect.tap(() => lifetime.revoked ? Effect.fail(denied()) : Effect.void), Effect.mapError((error) => isDeviceToolUnavailableError(error) || isDeviceActionUnavailableError(error) ? error : isDeviceOperationError(error) && error.reason === "agent_access_disabled" ? new DeviceToolUnavailableError({ reason: error.message }) : denied()));
57650
57986
  return yield* Effect.raceFirst(work, Deferred.await(lifetime.revocation).pipe(Effect.andThen(Effect.fail(denied()))));
57651
57987
  });
57652
57988
  const withDeviceSession = (target, execute) => withDeviceInvocation((service, threadId) => service.withAgentCommand({
@@ -58879,18 +59215,6 @@ const acceptBunDeviceSocket = Effect.fn("DeviceHubBunSocket.accept")(function* (
58879
59215
  }))), MAX_DEVICE_INPUT_BYTES, MAX_DEVICE_OUTPUT_BYTES);
58880
59216
  });
58881
59217
  //#endregion
58882
- //#region src/httpCompression/HttpResponseCompression.ts
58883
- var HttpResponseCompression = class extends Context.Service()("@p4code/cli/httpCompression/HttpResponseCompression") {};
58884
- const layerNode = Layer.effect(HttpResponseCompression, Effect.gen(function* () {
58885
- const NodeZlib = yield* Effect.promise(() => import("node:zlib"));
58886
- return { gzip: (body, options) => {
58887
- const stream = NodeZlib.createGzip();
58888
- stream.end(body);
58889
- return HttpServerResponse$1.raw(stream, options);
58890
- } };
58891
- }));
58892
- const layerBun = Layer.succeed(HttpResponseCompression, { gzip: (body, options) => HttpServerResponse$1.raw(new Response(body).body.pipeThrough(new CompressionStream("gzip")), options) });
58893
- //#endregion
58894
59218
  //#region ../../packages/shared/src/devProxy.ts
58895
59219
  /**
58896
59220
  * Backend paths the web dev server proxies in single-origin browser dev.
@@ -60562,7 +60886,6 @@ const LOOPBACK_HOSTNAMES = /* @__PURE__ */ new Set([
60562
60886
  "localhost"
60563
60887
  ]);
60564
60888
  const DESKTOP_RENDERER_ORIGINS = ["p4code://app", "p4code-dev://app"];
60565
- const GZIP_MIN_BYTES = 1024;
60566
60889
  const SVG_CONTENT_SECURITY_POLICY = "default-src 'none'; style-src 'unsafe-inline'; sandbox";
60567
60890
  function assetResponseHeaders(filePath) {
60568
60891
  return {
@@ -60571,36 +60894,6 @@ function assetResponseHeaders(filePath) {
60571
60894
  ...filePath.toLowerCase().endsWith(".svg") ? { "Content-Security-Policy": SVG_CONTENT_SECURITY_POLICY } : {}
60572
60895
  };
60573
60896
  }
60574
- function acceptsGzip(value) {
60575
- if (!value) return false;
60576
- const accepted = new Map(value.split(",").map((entry) => {
60577
- const [coding = "", ...parameters] = entry.trim().toLowerCase().split(";");
60578
- const quality = parameters.map((parameter) => parameter.trim().match(/^q=(.+)$/)?.[1]).find((parameter) => parameter !== void 0);
60579
- return [coding, quality === void 0 ? 1 : Number(quality)];
60580
- }));
60581
- return (accepted.get("gzip") ?? accepted.get("*") ?? 0) > 0;
60582
- }
60583
- function varyByAcceptEncoding(value) {
60584
- if (!value) return "Accept-Encoding";
60585
- const values = new Set(value.split(",").map((entry) => entry.trim().toLowerCase()));
60586
- return values.has("*") || values.has("accept-encoding") ? value : `${value}, Accept-Encoding`;
60587
- }
60588
- const compressHttpResponse = Effect.fnUntraced(function* (response, acceptEncoding) {
60589
- const body = response.body;
60590
- if (body._tag !== "Uint8Array" || body.contentLength < GZIP_MIN_BYTES || !body.contentType.startsWith("application/json") || response.headers["content-encoding"]) return response;
60591
- const variedResponse = HttpServerResponse.setHeader(response, "vary", varyByAcceptEncoding(response.headers.vary));
60592
- if (!acceptsGzip(acceptEncoding)) return variedResponse;
60593
- const compression = yield* HttpResponseCompression;
60594
- const headers = Headers$1.set(Headers$1.remove(variedResponse.headers, "content-length"), "content-encoding", "gzip");
60595
- return compression.gzip(body.body, {
60596
- status: response.status,
60597
- statusText: response.statusText,
60598
- headers,
60599
- cookies: response.cookies,
60600
- contentType: body.contentType
60601
- });
60602
- });
60603
- const httpCompressionLayer = HttpRouter.middleware((httpEffect) => Effect.flatMap(Effect.all([httpEffect, HttpServerRequest.HttpServerRequest]), ([response, request]) => compressHttpResponse(response, request.headers["accept-encoding"])), { global: true });
60604
60897
  const browserApiCorsLayer = Layer.unwrap(Effect.gen(function* () {
60605
60898
  const config = yield* ServerConfig$1;
60606
60899
  const devOrigin = config.devUrl?.origin;
@@ -69433,6 +69726,13 @@ const NARRATE_BEFORE_TOOLS_PROMPT = "Before each turn's first tool call, state n
69433
69726
  */
69434
69727
  const SUBAGENTS_ALLOWED_PROMPT = "You may spawn Task subagents without asking; this overrides earlier request-only rules. Use for bounded search/review whose result is far smaller than required reading. Single-file answer/edit stays inline.";
69435
69728
  const SUBAGENTS_ON_REQUEST_PROMPT = "Never spawn Task subagents unless user asks; this overrides earlier proactive permission. Work inline; say when delegation would have been better.";
69729
+ /**
69730
+ * Appended to ordinary (non-Fusion) sessions. Fusion role prompts already tell
69731
+ * both roles to follow applicable instructions and load memories and skills;
69732
+ * without the same line an ordinary thread treats the user's memory store as
69733
+ * optional background it may skip.
69734
+ */
69735
+ const INSTRUCTIONS_AND_MEMORY_PROMPT = "Follow applicable global and project instructions, and load relevant memories and skills on demand. When ~/.agent-memory exists, read its INDEX.md and the entries it points you to before acting.";
69436
69736
  //#endregion
69437
69737
  //#region src/provider/Layers/ClaudeAdapter.ts
69438
69738
  /**
@@ -70108,6 +70408,22 @@ const CLAUDE_SETTING_SOURCES = [
70108
70408
  "project",
70109
70409
  "local"
70110
70410
  ];
70411
+ /**
70412
+ * Claude user-scope MCP servers this session already receives under their
70413
+ * `p4-user-` alias, as `deniedMcpServers` entries.
70414
+ *
70415
+ * The `user` setting source still loads the originals from `~/.claude.json`,
70416
+ * where Claude has no credential for a server p4code authorized. An OAuth
70417
+ * server then appears twice, once working and once as "needs authentication",
70418
+ * and the agent believes the service is unavailable. Denying each original by
70419
+ * name leaves the working alias as the only copy. The deny matches by name in
70420
+ * every Claude scope, so a same-named project server is dropped too.
70421
+ */
70422
+ function claudeShadowedUserScopeMcpServers(sessionServerNames) {
70423
+ const prefix = mcpUserScopeSessionName("");
70424
+ const names = new Set(sessionServerNames);
70425
+ return sessionServerNames.filter((name) => name.startsWith(prefix)).map((name) => name.slice(prefix.length)).filter((original) => original.length > 0 && !names.has(original)).map((serverName) => ({ serverName }));
70426
+ }
70111
70427
  function buildPromptText(input, boundInstanceId, customModels = [], manifest) {
70112
70428
  const rawEffort = input.modelSelection?.instanceId === boundInstanceId ? getModelSelectionStringOptionValue(input.modelSelection, "effort") : null;
70113
70429
  const caps = getClaudeModelCapabilities(input.modelSelection?.instanceId === boundInstanceId ? input.modelSelection.model : void 0, customModels, manifest);
@@ -72143,14 +72459,16 @@ const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (claudeSettin
72143
72459
  }[input.runtimeMode];
72144
72460
  const disabledSkills = options?.resolveDisabledSkills === void 0 ? [] : yield* options.resolveDisabledSkills;
72145
72461
  const skillOverrides = disabledSkills.length === 0 ? void 0 : buildSkillOverrides(disabledSkills, yield* discoverClaudeSkills(claudeSettings, input.cwd, claudeEnvironment).pipe(Effect.provideService(FileSystem.FileSystem, fileSystem), Effect.provideService(Path$1.Path, path)));
72462
+ const mcpSession = readMcpProviderSession(input.threadId);
72463
+ const registeredMcpServers = Object.fromEntries(Object.entries(options?.resolveMcpServers === void 0 ? {} : yield* options.resolveMcpServers(input.cwd)).filter(([name]) => !providerMcpReservedNames().has(name)));
72464
+ const deniedMcpServers = claudeShadowedUserScopeMcpServers([...Object.keys(registeredMcpServers), ...providerMcpReservedNames()]);
72146
72465
  const settings = {
72147
72466
  ...typeof thinking === "boolean" ? { alwaysThinkingEnabled: thinking } : {},
72148
72467
  ...fastMode ? { fastMode: true } : {},
72149
72468
  ...ultracode ? { ultracode: true } : {},
72150
- ...skillOverrides ? { skillOverrides } : {}
72469
+ ...skillOverrides ? { skillOverrides } : {},
72470
+ ...deniedMcpServers.length > 0 ? { deniedMcpServers } : {}
72151
72471
  };
72152
- const mcpSession = readMcpProviderSession(input.threadId);
72153
- const registeredMcpServers = Object.fromEntries(Object.entries(options?.resolveMcpServers === void 0 ? {} : yield* options.resolveMcpServers(input.cwd)).filter(([name]) => !providerMcpReservedNames().has(name)));
72154
72472
  const narrateBeforeTools = options?.resolveToolCallNarration === void 0 ? DEFAULT_SERVER_SETTINGS.enableToolCallNarration : yield* options.resolveToolCallNarration;
72155
72473
  const guardrailSettings = options?.resolveGuardrailPrompts === void 0 ? {
72156
72474
  enableVerificationBeforeCompletion: DEFAULT_SERVER_SETTINGS.enableVerificationBeforeCompletion,
@@ -72171,7 +72489,7 @@ const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (claudeSettin
72171
72489
  ...narrateBeforeTools ? [NARRATE_BEFORE_TOOLS_PROMPT] : [],
72172
72490
  ...guardrailPromptsFor(guardrailSettings),
72173
72491
  unpromptedSubagents ? SUBAGENTS_ALLOWED_PROMPT : SUBAGENTS_ON_REQUEST_PROMPT,
72174
- ...input.fusionRole !== void 0 ? [fusionRoleInstructionsWith(input.fusionRole, "claudeAgent", fusionPromptSettings)] : [],
72492
+ ...input.fusionRole !== void 0 ? [fusionRoleInstructionsWith(input.fusionRole, "claudeAgent", fusionPromptSettings)] : [INSTRUCTIONS_AND_MEMORY_PROMPT],
72175
72493
  ...fusionLeanContextApplies(fusionContextPolicy) ? [FUSION_SAFETY_KERNEL] : [],
72176
72494
  ...compressRuleset !== void 0 ? [compressRuleset] : []
72177
72495
  ].join("\n\n");
@@ -109238,7 +109556,7 @@ const COMMON_DEV_PORTS = Object.freeze([
109238
109556
  8888,
109239
109557
  9e3
109240
109558
  ]);
109241
- const POLL_INTERVAL$2 = Duration.seconds(3);
109559
+ const POLL_INTERVAL$3 = Duration.seconds(3);
109242
109560
  const LSOF_TIMEOUT_MS = 5e3;
109243
109561
  const WINDOWS_LISTENER_TIMEOUT_MS = 5e3;
109244
109562
  const terminalOwnerKey = (owner) => `${owner.threadId}\u0000${owner.terminalId}`;
@@ -109413,7 +109731,7 @@ const make$61 = Effect.gen(function* PortDiscoveryMake() {
109413
109731
  lastSnapshot: next
109414
109732
  }])) yield* broadcast(next);
109415
109733
  }, Effect.catchCause((cause) => Effect.logWarning("preview port scan failed", Cause.pretty(cause))));
109416
- yield* Effect.forkScoped(pollTick().pipe(Effect.repeat(Schedule.spaced(POLL_INTERVAL$2))));
109734
+ yield* Effect.forkScoped(pollTick().pipe(Effect.repeat(Schedule.spaced(POLL_INTERVAL$3))));
109417
109735
  const acquireRetention = Effect.fn("PortDiscovery.retain")(function* () {
109418
109736
  if (yield* Ref.modify(stateRef, (state) => [state.retainCount === 0, {
109419
109737
  ...state,
@@ -124922,7 +125240,7 @@ const LINEAR_ISSUE_FIELDS = [
124922
125240
  * a Linear workspace is edited by people at human speed rather than by agents
124923
125241
  * at machine speed.
124924
125242
  */
124925
- const POLL_INTERVAL$1 = Duration.seconds(30);
125243
+ const POLL_INTERVAL$2 = Duration.seconds(30);
124926
125244
  /** Linear's cap on one page. Asking for more is an error, not a bigger page. */
124927
125245
  const LINEAR_PAGE_LIMIT = 250;
124928
125246
  const LINEAR_LIST_TOOL = "list_issues";
@@ -125175,7 +125493,7 @@ const makeLinearTaskRepository = Effect.gen(function* () {
125175
125493
  taskId
125176
125494
  });
125177
125495
  }).pipe(Effect.ignoreCause({ log: true }));
125178
- yield* Effect.forever(pollOnce.pipe(Effect.andThen(Effect.sleep(POLL_INTERVAL$1)))).pipe(Effect.forkScoped);
125496
+ yield* Effect.forever(pollOnce.pipe(Effect.andThen(Effect.sleep(POLL_INTERVAL$2)))).pipe(Effect.forkScoped);
125179
125497
  return {
125180
125498
  create,
125181
125499
  upsert,
@@ -125199,127 +125517,6 @@ const makeLinearTaskRepository = Effect.gen(function* () {
125199
125517
  };
125200
125518
  });
125201
125519
  //#endregion
125202
- //#region src/persistence/Layers/RemoteTasks.ts
125203
- /**
125204
- * The hub's task API, as a client.
125205
- *
125206
- * This used to be a `TaskRepository` in its own right, and the board was
125207
- * either this or the local database. It is now the transport underneath
125208
- * `SyncedTaskRepository`: local SQLite is a replica of the hub, so nothing
125209
- * reads from here directly and the board no longer goes dark when the hub is
125210
- * unreachable. What changed with it is the failure story — an unreachable hub
125211
- * used to mean no board, and now means a board that is behind.
125212
- *
125213
- * The four calls here are what a mirror needs and no more: read the hub's
125214
- * rows, push a whole row, arbitrate a claim, and delete. There is no partial
125215
- * patch, because a mirror always holds the full row it wants the hub to have
125216
- * and sending a diff of it would only add a way for the two to disagree.
125217
- *
125218
- * @module persistence/Layers/RemoteTasks
125219
- */
125220
- const HUB_REQUEST_TIMEOUT_MS = 15e3;
125221
- const TaskListPage = Schema$1.Struct({ tasks: Schema$1.Array(Task) });
125222
- const ConflictBody = Schema$1.Struct({ current: Task });
125223
- const decodeTaskListPage = Schema$1.decodeUnknownEffect(TaskListPage);
125224
- const decodeTask = Schema$1.decodeUnknownEffect(Task);
125225
- const decodeConflictBody = Schema$1.decodeUnknownEffect(ConflictBody);
125226
- /**
125227
- * Every failure here is reported as the persistence error type.
125228
- *
125229
- * The mirror catches these and carries on — a failed push leaves the row
125230
- * `pending` and the next pass retries it — so what matters is that the detail
125231
- * says the hub was the store that would not answer, which is what shows up in
125232
- * the log when someone asks why their two machines disagree.
125233
- */
125234
- const hubUnavailable = (operation, detail) => new PersistenceSqlError({
125235
- operation: `HubTaskClient.${operation}`,
125236
- detail: `The hub board is unavailable: ${detail}`,
125237
- cause: null
125238
- });
125239
- var HubTaskClient = class extends Context.Service()("@p4code/cli/persistence/Layers/RemoteTasks/HubTaskClient") {};
125240
- const makeHubTaskClient = Effect.gen(function* () {
125241
- const http = yield* HttpClient.HttpClient;
125242
- const link = yield* HubLink;
125243
- const isLinked = link.current.pipe(Effect.map((state) => Option.isSome(state.settings)));
125244
- const requireSettings = (operation) => Effect.gen(function* () {
125245
- const state = yield* link.current;
125246
- if (Option.isNone(state.settings)) return yield* hubUnavailable(operation, "no hub is configured on this server");
125247
- return state.settings.value;
125248
- });
125249
- const send = (operation, settings, request) => http.execute(request.pipe(HttpClientRequest.setHeader("authorization", `Bearer ${settings.token}`), HttpClientRequest.setHeader("accept", "application/json"))).pipe(Effect.timeout(HUB_REQUEST_TIMEOUT_MS), Effect.mapError((cause) => hubUnavailable(operation, `${cause._tag ?? "request failed"}`)));
125250
- const list = (filter) => Effect.gen(function* () {
125251
- const settings = yield* requireSettings("list");
125252
- const query = new URLSearchParams();
125253
- for (const key of [
125254
- "projectId",
125255
- "repositoryKey",
125256
- "threadId",
125257
- "status",
125258
- "assignee",
125259
- "query"
125260
- ]) {
125261
- const value = filter[key];
125262
- if (value !== void 0) query.set(key, value);
125263
- }
125264
- const suffix = query.size === 0 ? "" : `?${query.toString()}`;
125265
- const response = yield* send("list", settings, HttpClientRequest.get(`${settings.baseUrl}/tasks${suffix}`));
125266
- if (response.status !== 200) return yield* hubUnavailable("list", `status ${response.status}`);
125267
- return (yield* response.json.pipe(Effect.flatMap(decodeTaskListPage), Effect.mapError(() => hubUnavailable("list", "the response did not match the contract")))).tasks;
125268
- });
125269
- const put = (row, readableIdPrefix) => Effect.gen(function* () {
125270
- const settings = yield* requireSettings("put");
125271
- const response = yield* send("put", settings, HttpClientRequest.bodyJsonUnsafe(HttpClientRequest.put(`${settings.baseUrl}/tasks/${encodeURIComponent(row.taskId)}`), {
125272
- projectId: row.projectId,
125273
- repositoryKey: row.repositoryKey,
125274
- threadId: row.threadId,
125275
- parentTaskId: row.parentTaskId,
125276
- title: row.title,
125277
- body: row.body,
125278
- status: row.status,
125279
- priority: row.priority,
125280
- assignee: row.assignee,
125281
- labels: row.labels,
125282
- orderKey: row.orderKey,
125283
- createdAt: row.createdAt,
125284
- statusChangedAt: row.statusChangedAt ?? row.updatedAt,
125285
- readableIdPrefix
125286
- }));
125287
- if (response.status !== 200 && response.status !== 201) return yield* hubUnavailable("put", `status ${response.status}`);
125288
- return yield* response.json.pipe(Effect.flatMap(decodeTask), Effect.mapError(() => hubUnavailable("put", "the response did not match the contract")));
125289
- });
125290
- const patchIfStatus = (input) => Effect.gen(function* () {
125291
- const settings = yield* requireSettings("patchIfStatus");
125292
- const { taskId, ...fields } = input.patch;
125293
- const response = yield* send("patchIfStatus", settings, HttpClientRequest.bodyJsonUnsafe(HttpClientRequest.patch(`${settings.baseUrl}/tasks/${encodeURIComponent(taskId)}`), {
125294
- ...fields,
125295
- expectedStatus: input.expectedStatus
125296
- }));
125297
- if (response.status === 404) return { outcome: "missing" };
125298
- if (response.status === 409) return {
125299
- outcome: "conflict",
125300
- current: (yield* response.json.pipe(Effect.flatMap(decodeConflictBody), Effect.mapError(() => hubUnavailable("patchIfStatus", "the conflict response did not match the contract")))).current
125301
- };
125302
- if (response.status !== 200) return yield* hubUnavailable("patchIfStatus", `status ${response.status}`);
125303
- return {
125304
- outcome: "updated",
125305
- task: yield* response.json.pipe(Effect.flatMap(decodeTask), Effect.mapError(() => hubUnavailable("patchIfStatus", "the response did not match the contract")))
125306
- };
125307
- });
125308
- const remove = (taskId) => Effect.gen(function* () {
125309
- const settings = yield* requireSettings("remove");
125310
- const response = yield* send("remove", settings, HttpClientRequest.delete(`${settings.baseUrl}/tasks/${encodeURIComponent(taskId)}`));
125311
- if (response.status !== 200 && response.status !== 204 && response.status !== 404) return yield* hubUnavailable("remove", `status ${response.status}`);
125312
- });
125313
- return {
125314
- isLinked,
125315
- list,
125316
- put,
125317
- patchIfStatus,
125318
- remove
125319
- };
125320
- });
125321
- Layer.effect(HubTaskClient, makeHubTaskClient);
125322
- //#endregion
125323
125520
  //#region src/persistence/Layers/TaskSyncStore.ts
125324
125521
  /**
125325
125522
  * The bookkeeping a mirror needs and a board does not.
@@ -125516,14 +125713,17 @@ const makeTaskSyncStore = Effect.gen(function* () {
125516
125713
  * @module persistence/Layers/SyncedTasks
125517
125714
  */
125518
125715
  /**
125519
- * How stale another machine's write may look here.
125716
+ * How stale another machine's write may look here when the hub cannot push.
125520
125717
  *
125521
- * Short enough that a board left open follows along, long enough that two
125522
- * servers idling all day are not a steady stream of requests at a hosted
125523
- * service. Only changes made elsewhere wait for it; a write made here is
125524
- * already on screen.
125718
+ * With the hub's change stream connected, a write elsewhere triggers a pull
125719
+ * right away and the timer is only a safety net, so it can be slow. Without
125720
+ * it (an older hub, or the stream is reconnecting) the timer is the only
125721
+ * signal, and a board left open should still follow along.
125525
125722
  */
125526
- const SYNC_INTERVAL = Duration.seconds(10);
125723
+ const POLL_INTERVAL$1 = Duration.seconds(10);
125724
+ const PUSHED_POLL_INTERVAL = Duration.minutes(5);
125725
+ /** Between change-stream attempts, so an older hub's 404 is not hammered. */
125726
+ const WATCH_RETRY_DELAY = Duration.seconds(30);
125527
125727
  const makeSyncedTaskRepository = Effect.gen(function* () {
125528
125728
  const local = yield* makeTaskRepository;
125529
125729
  const sync = yield* makeTaskSyncStore;
@@ -125610,6 +125810,11 @@ const makeSyncedTaskRepository = Effect.gen(function* () {
125610
125810
  * that was just removed; a pull run before either would see this machine's
125611
125811
  * own stale absence as the hub's opinion and undo local work.
125612
125812
  */
125813
+ /**
125814
+ * Where the last applied pull left off. Held in memory only: a restart pulls
125815
+ * the whole board once, then follows changes again.
125816
+ */
125817
+ let position;
125613
125818
  const syncOnce = Effect.gen(function* () {
125614
125819
  if (!(yield* hub.isLinked)) return;
125615
125820
  for (const taskId of yield* sync.listTombstones) yield* hub.remove(taskId).pipe(Effect.andThen(sync.clearTombstone(taskId)), Effect.ignoreCause({ log: true }));
@@ -125617,21 +125822,36 @@ const makeSyncedTaskRepository = Effect.gen(function* () {
125617
125822
  const { readableIdPrefix, ...task } = row;
125618
125823
  yield* pushInBackground(task, readableIdPrefix ?? "T");
125619
125824
  }
125620
- const remote = yield* hub.list({});
125825
+ const pulled = yield* hub.pull(position);
125826
+ if (Option.isNone(pulled)) return;
125827
+ const page = pulled.value;
125621
125828
  const stillPending = new Set((yield* sync.listPending).map((row) => row.taskId));
125622
125829
  const tombstoned = new Set(yield* sync.listTombstones);
125623
125830
  const syncedIds = yield* sync.listSyncedIds;
125624
125831
  const held = new Map((yield* local.list({})).map((task) => [task.taskId, task]));
125625
125832
  const seen = /* @__PURE__ */ new Set();
125626
- for (const task of remote) {
125833
+ for (const task of page.tasks) {
125627
125834
  seen.add(task.taskId);
125628
125835
  if (stillPending.has(task.taskId) || tombstoned.has(task.taskId)) continue;
125629
125836
  const current = held.get(task.taskId);
125630
125837
  if (current === void 0 || current.updatedAt !== task.updatedAt) yield* adopt(task);
125631
125838
  }
125632
- for (const taskId of syncedIds) if (!seen.has(taskId)) yield* local.deleteById({ taskId });
125839
+ const gone = page.full ? syncedIds.filter((taskId) => !seen.has(taskId)) : page.deletedTaskIds.filter((taskId) => syncedIds.includes(taskId));
125840
+ for (const taskId of gone) yield* local.deleteById({ taskId });
125841
+ position = page.next;
125633
125842
  }).pipe(Effect.ignoreCause({ log: true }));
125634
- yield* Effect.forever(syncOnce.pipe(Effect.andThen(Effect.sleep(SYNC_INTERVAL)))).pipe(Effect.forkScoped);
125843
+ const nudges = yield* Queue.sliding(1);
125844
+ let watching = false;
125845
+ yield* hub.watch.pipe(Stream.runForEach(() => Effect.sync(() => {
125846
+ watching = true;
125847
+ }).pipe(Effect.andThen(Queue.offer(nudges, void 0)))), Effect.ignoreCause({ log: "Debug" }), Effect.andThen(Effect.sync(() => {
125848
+ watching = false;
125849
+ }).pipe(Effect.andThen(Queue.offer(nudges, void 0)))), Effect.andThen(Effect.sleep(WATCH_RETRY_DELAY)), Effect.forever, Effect.forkScoped);
125850
+ const nextPass = Effect.gen(function* () {
125851
+ const owed = (yield* sync.listPending).length > 0 || (yield* sync.listTombstones).length > 0;
125852
+ return yield* Queue.take(nudges).pipe(Effect.timeoutOption(watching && !owed ? PUSHED_POLL_INTERVAL : POLL_INTERVAL$1));
125853
+ }).pipe(Effect.catch(() => Effect.sleep(POLL_INTERVAL$1)));
125854
+ yield* Effect.forever(syncOnce.pipe(Effect.andThen(nextPass))).pipe(Effect.forkScoped);
125635
125855
  return {
125636
125856
  create,
125637
125857
  upsert,
@@ -133683,7 +133903,7 @@ const ProviderAdapterRegistryLive = Layer.effect(ProviderAdapterRegistry, makePr
133683
133903
  //#endregion
133684
133904
  //#region src/provider/ComputerUsePrompts.ts
133685
133905
  /** Routing guidance is injected only when the session has P4's Computer Use endpoint. */
133686
- const COMPUTER_USE_ROUTING_PROMPT = "P4 Computer Use is available for this session through the cua_repl MCP server. When you use it for a native desktop app, do not substitute AppleScript or shell automation unless the user explicitly requests that path. For a named app, start directly with const app = await cua.getApp(exactNameOrBundleId); read the automatically returned state/image and stop if it answers the request. Reuse the handle and batch safe actions; do not repeat selection or unchanged state. For Device Hub, simulator, emulator, or connected-device requests, use cua.listDevices() once, then cua.getDevice({hostId,deviceId,platform}) with exact discovered IDs, unless the user explicitly requested a different route such as simctl. Selection already emits initial device state. Reuse its handle; do not print the handle or repeat that state before acting. Never substitute macOS getApp('Simulator'). Device hosts are scoped to this provider environment and can differ from the desktop host. If device discovery, opening, inspection, or interaction reports missing permission or unavailable control, stop P4 device interaction and report the exact limitation. Do not silently retry through screenshots, shell simctl, Xcode tools, or another MCP server; an explicit user request for one of those routes is separate authorization to use it. Do not call getState/getApp, control the P4 desktop app, or open device settings to work around a device API failure. Missing consent, unavailable devices, or unsupported operations are P4 limitations to report, never permission to switch P4 hosts or bypass the tool.";
133906
+ const COMPUTER_USE_ROUTING_PROMPT = "P4 Computer Use is available for this session through the cua_repl MCP server. When you use it for a native desktop app, do not substitute AppleScript or shell automation unless the user explicitly requests that path. For a named app, start directly with const app = await cua.getApp(exactNameOrBundleId); read the automatically returned state/image and stop if it answers the request. Reuse the handle and batch safe actions; do not repeat selection or unchanged state. For Device Hub, simulator, emulator, or connected-device requests, use cua.listDevices() once, then cua.getDevice({hostId,deviceId,platform}) with exact discovered IDs, unless the user explicitly requested a different route such as simctl. Selection already emits initial device state. Reuse its handle; do not print the handle or repeat that state before acting. Never substitute macOS getApp('Simulator'). Device hosts are scoped to this provider environment and can differ from the desktop host. If device discovery, opening, inspection, or interaction reports missing permission or unavailable control, stop P4 device interaction and report the exact limitation. Do not silently retry through screenshots, shell simctl, Xcode tools, or another MCP server; an explicit user request for one of those routes, including a standing fallback authorization in the user's global or project instructions, is separate authorization to use it; report the P4 error and the fallback you used. Do not call getState/getApp, control the P4 desktop app, or open device settings to work around a device API failure. Missing consent, unavailable devices, or unsupported operations are P4 limitations to report, never on their own permission to switch P4 hosts or bypass the tool.";
133687
133907
  /** The routing note, naming the server the way this session's provider sees it. */
133688
133908
  function withComputerUseRouting(input, enabled, serverName = "cua_repl") {
133689
133909
  const prompt = serverName === "cua_repl" ? COMPUTER_USE_ROUTING_PROMPT : COMPUTER_USE_ROUTING_PROMPT.replace("the cua_repl MCP server", `the ${serverName} MCP server`);
@@ -148286,8 +148506,8 @@ const WorkspaceLayerLive = Layer.mergeAll(layer$71, WorkspaceEntriesLayerLive, W
148286
148506
  const ProjectFaviconResolverLayerLive = layer$66.pipe(Layer.provide(layer$71), Layer.provide(layer$67));
148287
148507
  const AuthLayerLive = layer$116.pipe(Layer.provideMerge(PersistenceLayerLive), Layer.provide(layer$120));
148288
148508
  const ProviderRuntimeLayerLive = Layer.mergeAll(ProviderSessionReaperLive, ProviderHealthRefresherLive).pipe(Layer.provideMerge(ProviderLayerLive), Layer.provideMerge(OrchestrationLayerLive));
148289
- const RuntimeCoreDependenciesLive = ReactorLayerLive.pipe(Layer.provideMerge(layer$28), Layer.provideMerge(Layer.mergeAll(PullRequestServiceLive, CheckpointingLayerLive, SourceControlProviderRegistryLayerLive)), Layer.provideMerge(GitLayerLive), Layer.provideMerge(VcsLayerLive), Layer.provideMerge(ProviderAuthServiceLive), Layer.provideMerge(ProviderRuntimeLayerLive), Layer.provideMerge(Layer.mergeAll(TerminalLayerLive, PreviewLayerLive, RoutedTaskRepositoryLive)), Layer.provideMerge(Layer.mergeAll(PersistenceLayerLive, layer$90)), Layer.provideMerge(layer$84), Layer.provideMerge(ProviderRegistryLive), Layer.provideMerge(layer$61), Layer.provideMerge(Layer.mergeAll(layer$60, layer$73.pipe(Layer.provide(layer$86)), layer$75, layer$70)), Layer.provideMerge(layer$55), Layer.provideMerge(layer$56), Layer.provideMerge(layer$57), Layer.provideMerge(layer$58), Layer.provideMerge(layer$59)).pipe(Layer.provideMerge(ProviderInstanceRegistryHydrationLive), Layer.provideMerge(AntigravityInstallation.layer), Layer.provideMerge(ProviderEventLoggersLive), Layer.provideMerge(OpenCodeRuntimeLive), Layer.provideMerge(layer$63), Layer.provideMerge(JevWorkspaceAdvisorLive.pipe(Layer.provide(TypeSafeClientLive))), Layer.provideMerge(layer$94.pipe(Layer.provide(layer$120))), Layer.provideMerge(SupervisorSecretsLive.pipe(Layer.provide(layer$120))), Layer.provideMerge(WorkspaceLayerLive), Layer.provideMerge(ProjectFaviconResolverLayerLive), Layer.provideMerge(layer$85), Layer.provideMerge(layer$69), Layer.provideMerge(remoteComputerUseBindingsLayer.pipe(Layer.provide(layer$90))), Layer.provideMerge(layer$78), Layer.provideMerge(AuthLayerLive), Layer.provideMerge(layer$5.pipe(Layer.provideMerge(LinearClientLive), Layer.provideMerge(Layer.mergeAll(LinearMcpClientLive, TrackerSecretsLive)), Layer.provideMerge(Layer.mergeAll(layer$91, layer$21).pipe(Layer.provideMerge(layer$92), Layer.provideMerge(layer$93))))), Layer.provideMerge(layer$120));
148290
- const UsageLayerLive = layer$25.pipe(Layer.provide(layer$94.pipe(Layer.provide(layer$120))));
148509
+ const RuntimeCoreDependenciesLive = ReactorLayerLive.pipe(Layer.provideMerge(layer$28), Layer.provideMerge(Layer.mergeAll(PullRequestServiceLive, CheckpointingLayerLive, SourceControlProviderRegistryLayerLive)), Layer.provideMerge(GitLayerLive), Layer.provideMerge(VcsLayerLive), Layer.provideMerge(ProviderAuthServiceLive), Layer.provideMerge(ProviderRuntimeLayerLive), Layer.provideMerge(Layer.mergeAll(TerminalLayerLive, PreviewLayerLive, RoutedTaskRepositoryLive)), Layer.provideMerge(Layer.mergeAll(PersistenceLayerLive, layer$95)), Layer.provideMerge(layer$84), Layer.provideMerge(ProviderRegistryLive), Layer.provideMerge(layer$61), Layer.provideMerge(Layer.mergeAll(layer$60, layer$73.pipe(Layer.provide(layer$86)), layer$75, layer$70)), Layer.provideMerge(layer$55), Layer.provideMerge(layer$56), Layer.provideMerge(layer$57), Layer.provideMerge(layer$58), Layer.provideMerge(layer$59)).pipe(Layer.provideMerge(ProviderInstanceRegistryHydrationLive), Layer.provideMerge(AntigravityInstallation.layer), Layer.provideMerge(ProviderEventLoggersLive), Layer.provideMerge(OpenCodeRuntimeLive), Layer.provideMerge(layer$63), Layer.provideMerge(JevWorkspaceAdvisorLive.pipe(Layer.provide(TypeSafeClientLive))), Layer.provideMerge(layer$93.pipe(Layer.provide(layer$120))), Layer.provideMerge(SupervisorSecretsLive.pipe(Layer.provide(layer$120))), Layer.provideMerge(WorkspaceLayerLive), Layer.provideMerge(ProjectFaviconResolverLayerLive), Layer.provideMerge(layer$85), Layer.provideMerge(layer$69), Layer.provideMerge(remoteComputerUseBindingsLayer.pipe(Layer.provide(layer$95))), Layer.provideMerge(layer$78), Layer.provideMerge(AuthLayerLive), Layer.provideMerge(layer$5.pipe(Layer.provideMerge(LinearClientLive), Layer.provideMerge(Layer.mergeAll(LinearMcpClientLive, TrackerSecretsLive)), Layer.provideMerge(Layer.mergeAll(layer$90, layer$21).pipe(Layer.provideMerge(layer$91), Layer.provideMerge(layer$92))))), Layer.provideMerge(layer$120));
148510
+ const UsageLayerLive = layer$25.pipe(Layer.provide(layer$93.pipe(Layer.provide(layer$120))));
148291
148511
  const RuntimeDependenciesLive = layer$24.pipe(Layer.provideMerge(RuntimeCoreDependenciesLive)).pipe(Layer.provideMerge(UsageLayerLive), Layer.provideMerge(layer$12), Layer.provideMerge(layer$11), Layer.provideMerge(layer$10), Layer.provideMerge(layer$81), Layer.provideMerge(layer$83), Layer.provideMerge(layer$82), Layer.provide(layer$124));
148292
148512
  /**
148293
148513
  * Hub asset sync.
@@ -148296,7 +148516,7 @@ const RuntimeDependenciesLive = layer$24.pipe(Layer.provideMerge(RuntimeCoreDepe
148296
148516
  * not-configured shape, and every call reports that rather than failing — so
148297
148517
  * this costs a server with no hub nothing but a layer.
148298
148518
  */
148299
- const AssetSyncLive = layer$88.pipe(Layer.provideMerge(layer$77), Layer.provideMerge(layer$89), Layer.provideMerge(layer$90));
148519
+ const AssetSyncLive = layer$88.pipe(Layer.provideMerge(layer$77), Layer.provideMerge(layer$89), Layer.provideMerge(layer$95));
148300
148520
  const RuntimeServicesLive = layer$72.pipe(Layer.provideMerge(layer$1), Layer.provideMerge(AssetSyncLive), Layer.provideMerge(layer$23), Layer.provideMerge(layer$22.pipe(Layer.provide(layer$53), Layer.provide(ProviderSessionDirectoryLayerLive), Layer.provide(ProjectionProjectRepositoryLive))), Layer.provideMerge(RuntimeDependenciesLive));
148301
148521
  const makeRoutesLayer = Layer.mergeAll(Layer.mergeAll(HttpApiBuilder.layer(EnvironmentHttpApi).pipe(Layer.provide(authHttpApiLayer), Layer.provide(orchestrationHttpApiLayer), Layer.provide(pullRequestHttpApiLayer), Layer.provide(serverEnvironmentHttpApiLayer), Layer.provide(environmentAuthenticatedAuthLayer)), otlpTracesProxyRouteLayer, assetRouteLayer, mcpOAuthCallbackRouteLayer, staticAndDevRouteLayer, websocketRpcRouteLayer), layer$4.pipe(Layer.provide(layer$6)), layer$68, layer).pipe(Layer.provide(ProjectionProjectRepositoryLive.pipe(Layer.provide(PersistenceLayerLive))), Layer.provide(ProjectionThreadRepositoryLive.pipe(Layer.provide(PersistenceLayerLive))), Layer.provide(layer$67), Layer.provide(layer$28), Layer.provide(PullRequestServiceLive), Layer.provide(layer$20), Layer.provide(layer$79), Layer.provide(layer$26), Layer.provide(browserApiCorsLayer), Layer.provide(httpCompressionLayer));
148302
148522
  const makeServerLayer = Layer.unwrap(Effect.gen(function* () {