@parall/sdk 1.20.1 → 1.21.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/client.ts CHANGED
@@ -4,6 +4,7 @@ import type {
4
4
  RegisterRequest,
5
5
  RegisterResponse,
6
6
  LoginRequest,
7
+ ChangePasswordRequest,
7
8
  CheckEmailResponse,
8
9
  User,
9
10
  Organization,
@@ -44,11 +45,16 @@ import type {
44
45
  Project,
45
46
  CreateProjectRequest,
46
47
  UpdateProjectRequest,
48
+ Schedule,
49
+ ScheduleRun,
50
+ CreateScheduleInput,
51
+ UpdateScheduleInput,
52
+ ScheduleFilters,
53
+ ScheduleRunFilters,
47
54
  AgentSessionDB,
48
55
  AgentStep,
49
56
  CreateAgentSessionRequest,
50
57
  CreateAgentStepRequest,
51
- CreateAgentTaskBackfillCommentRequest,
52
58
  UpdateAgentSessionRequest,
53
59
  OrgInvitation,
54
60
  InvitationPublicInfo,
@@ -56,12 +62,16 @@ import type {
56
62
  Wiki,
57
63
  WikiBlob,
58
64
  WikiChangeset,
65
+ WikiFileUploadResponse,
66
+ WikiFilePreviewUrlResponse,
59
67
  WikiTreeResponse,
60
68
  WikiNodeSectionArtifact,
61
69
  WikiSearchResponse,
62
70
  WikiPageIndex,
63
71
  WikiRefsResponse,
64
72
  WikiRefsCheckResponse,
73
+ WikiAnchorStatusRequest,
74
+ WikiAnchorStatusResponse,
65
75
  WikiDiff,
66
76
  WikiPathScope,
67
77
  WikiAccessStatus,
@@ -290,6 +300,68 @@ export class ParallClient {
290
300
  return res.json() as Promise<T>;
291
301
  }
292
302
 
303
+ /**
304
+ * Multipart upload variant of `request`. Same auth / refresh / error
305
+ * handling, but lets the caller hand us a prepared `FormData` (file +
306
+ * text fields) and skips the JSON content-type. Used by
307
+ * uploadWikiFile / uploadWikiFileToChangeset — wiki binary uploads can
308
+ * hit the 100 MiB cap, so a longer 5-minute timeout is used so a
309
+ * 50 MiB blob on a slow connection doesn't get chopped at 15 s.
310
+ */
311
+ private async multipartRequest<T>(
312
+ method: string,
313
+ path: string,
314
+ body: FormData,
315
+ retried = false,
316
+ ): Promise<T> {
317
+ if (!retried) {
318
+ await this.ensureFreshToken(path);
319
+ }
320
+ // Don't set Content-Type — the browser fills in the boundary.
321
+ const { 'Content-Type': _drop, ...headers } = this.buildHeaders();
322
+ void _drop;
323
+
324
+ let res: Response;
325
+ try {
326
+ res = await fetch(`${this.baseUrl}${path}`, {
327
+ method,
328
+ headers,
329
+ body,
330
+ signal: AbortSignal.timeout(5 * 60 * 1000),
331
+ });
332
+ } catch (err) {
333
+ if (err instanceof DOMException && err.name === 'TimeoutError') {
334
+ throw new ApiError(0, 'Request timed out', 'REQUEST_TIMEOUT');
335
+ }
336
+ throw err;
337
+ }
338
+
339
+ if (res.status === 401) {
340
+ const pathSuffix = path.replace(/^\/api\/v1/, '');
341
+ const isAuthPath = ParallClient.AUTH_PATHS.has(pathSuffix);
342
+ if (!retried && !isAuthPath && this.getRefreshToken) {
343
+ const refreshed = await this.tryRefresh();
344
+ if (refreshed) {
345
+ return this.multipartRequest<T>(method, path, body, true);
346
+ }
347
+ }
348
+ if (this.onTokenExpired && !isAuthPath) {
349
+ this.onTokenExpired();
350
+ }
351
+ }
352
+
353
+ if (!res.ok) {
354
+ const errorBody = await res.json().catch(() => ({}));
355
+ const errorObj = errorBody?.error && typeof errorBody.error === 'object' ? errorBody.error as { message?: string; code?: string } : undefined;
356
+ const errMsg = errorObj?.message ?? (typeof errorBody?.error === 'string' ? errorBody.error : undefined);
357
+ const errCode = errorObj?.code ?? (typeof errorBody?.code === 'string' ? errorBody.code : undefined);
358
+ throw new ApiError(res.status, errMsg ?? res.statusText, errCode);
359
+ }
360
+
361
+ if (res.status === 204) return undefined as T;
362
+ return res.json() as Promise<T>;
363
+ }
364
+
293
365
  // ---- Auth ----
294
366
 
295
367
  async register(req: RegisterRequest): Promise<RegisterResponse> {
@@ -308,6 +380,10 @@ export class ParallClient {
308
380
  return this.request('POST', ENDPOINTS.AUTH_LOGOUT);
309
381
  }
310
382
 
383
+ async changePassword(req: ChangePasswordRequest): Promise<void> {
384
+ return this.request('POST', ENDPOINTS.AUTH_CHANGE_PASSWORD, req);
385
+ }
386
+
311
387
  async checkEmail(email: string): Promise<CheckEmailResponse> {
312
388
  return this.request('POST', ENDPOINTS.AUTH_CHECK_EMAIL, { email });
313
389
  }
@@ -679,15 +755,6 @@ export class ParallClient {
679
755
  return this.request('POST', ENDPOINTS.AGENT_SESSION_STEPS(orgId, agentId, sessionId), req);
680
756
  }
681
757
 
682
- async createAgentTaskBackfillComment(
683
- orgId: string,
684
- agentId: string,
685
- sessionId: string,
686
- req: CreateAgentTaskBackfillCommentRequest,
687
- ): Promise<Comment> {
688
- return this.request('POST', ENDPOINTS.AGENT_SESSION_TASK_BACKFILL_COMMENTS(orgId, agentId, sessionId), req);
689
- }
690
-
691
758
  async getAgentSessionSteps(orgId: string, agentId: string, sessionId: string, params?: { limit?: number }): Promise<AgentStep[]> {
692
759
  const res = await this.request<{ data: AgentStep[] }>('GET', ENDPOINTS.AGENT_SESSION_STEPS(orgId, agentId, sessionId), undefined, params);
693
760
  return res.data;
@@ -1067,6 +1134,57 @@ export class ParallClient {
1067
1134
  return this.request('DELETE', ENDPOINTS.PROJECT(orgId, projectId));
1068
1135
  }
1069
1136
 
1137
+ // ---- Schedules (org-scoped) ----
1138
+
1139
+ async createSchedule(orgId: string, input: CreateScheduleInput): Promise<Schedule> {
1140
+ return this.request('POST', ENDPOINTS.SCHEDULES(orgId), input);
1141
+ }
1142
+
1143
+ async listSchedules(orgId: string, filters?: ScheduleFilters): Promise<PaginatedResponse<Schedule>> {
1144
+ return this.request('GET', ENDPOINTS.SCHEDULES(orgId), undefined, filters as Record<string, string | number | undefined>);
1145
+ }
1146
+
1147
+ async getSchedule(orgId: string, scheduleId: string): Promise<Schedule> {
1148
+ return this.request('GET', ENDPOINTS.SCHEDULE(orgId, scheduleId));
1149
+ }
1150
+
1151
+ async updateSchedule(orgId: string, scheduleId: string, patch: UpdateScheduleInput): Promise<Schedule> {
1152
+ return this.request('PATCH', ENDPOINTS.SCHEDULE(orgId, scheduleId), patch);
1153
+ }
1154
+
1155
+ async pauseSchedule(orgId: string, scheduleId: string): Promise<Schedule> {
1156
+ return this.request('POST', ENDPOINTS.SCHEDULE_PAUSE(orgId, scheduleId));
1157
+ }
1158
+
1159
+ async resumeSchedule(orgId: string, scheduleId: string): Promise<Schedule> {
1160
+ return this.request('POST', ENDPOINTS.SCHEDULE_RESUME(orgId, scheduleId));
1161
+ }
1162
+
1163
+ async cancelSchedule(orgId: string, scheduleId: string): Promise<Schedule> {
1164
+ return this.request('POST', ENDPOINTS.SCHEDULE_CANCEL(orgId, scheduleId));
1165
+ }
1166
+
1167
+ async deleteSchedule(orgId: string, scheduleId: string): Promise<void> {
1168
+ return this.request('DELETE', ENDPOINTS.SCHEDULE(orgId, scheduleId));
1169
+ }
1170
+
1171
+ async listScheduleRuns(
1172
+ orgId: string,
1173
+ scheduleId: string,
1174
+ filters?: ScheduleRunFilters,
1175
+ ): Promise<PaginatedResponse<ScheduleRun>> {
1176
+ return this.request(
1177
+ 'GET',
1178
+ ENDPOINTS.SCHEDULE_RUNS(orgId, scheduleId),
1179
+ undefined,
1180
+ filters as Record<string, string | number | undefined>,
1181
+ );
1182
+ }
1183
+
1184
+ async getScheduleRun(orgId: string, runId: string): Promise<ScheduleRun> {
1185
+ return this.request('GET', ENDPOINTS.SCHEDULE_RUN(orgId, runId));
1186
+ }
1187
+
1070
1188
  // ---- Wikis (org-scoped) ----
1071
1189
 
1072
1190
  async createWiki(orgId: string, data: CreateWikiRequest): Promise<Wiki> {
@@ -1110,6 +1228,16 @@ export class ParallClient {
1110
1228
  return this.request('GET', ENDPOINTS.WIKI_REFS_CHECK(orgId, wikiId), undefined, params);
1111
1229
  }
1112
1230
 
1231
+ // Anchor-status: batch blob-SHA compare for inline-comment outdated judging.
1232
+ // Refs the caller cannot read are silently dropped from the response.
1233
+ async getWikiAnchorStatus(
1234
+ orgId: string,
1235
+ wikiId: string,
1236
+ body: WikiAnchorStatusRequest,
1237
+ ): Promise<WikiAnchorStatusResponse> {
1238
+ return this.request('POST', ENDPOINTS.WIKI_ANCHOR_STATUS(orgId, wikiId), body);
1239
+ }
1240
+
1113
1241
  async createWikiChangeset(orgId: string, wikiId: string, data: CreateWikiChangesetRequest): Promise<WikiChangeset> {
1114
1242
  return normalizeWikiChangeset(await this.request('POST', ENDPOINTS.WIKI_CHANGESETS(orgId, wikiId), data));
1115
1243
  }
@@ -1144,6 +1272,85 @@ export class ParallClient {
1144
1272
  return normalizeWikiChangeset(await this.request('POST', ENDPOINTS.WIKI_CHANGESET_CLOSE(orgId, wikiId, changesetId)));
1145
1273
  }
1146
1274
 
1275
+ // ---- Wiki Binary Upload / Delete (Phase 2 of wiki-file-storage-design) ----
1276
+
1277
+ /**
1278
+ * Upload a binary file directly to the wiki's default branch (maintain
1279
+ * scope). Size ≤ 1 MiB lands as an inline Git blob; > 1 MiB lands as a
1280
+ * Git-LFS pointer backed by S3. Text files return 422 — they must go
1281
+ * through createWikiChangeset / updateWikiChangeset.
1282
+ */
1283
+ async uploadWikiFile(
1284
+ orgId: string,
1285
+ wikiId: string,
1286
+ params: { path: string; file: Blob; message?: string },
1287
+ ): Promise<WikiFileUploadResponse> {
1288
+ // `POST /uploads` always writes to the wiki's default branch — the
1289
+ // backend's parseUpload silently drops any `parent_ref` field, so
1290
+ // surfacing one in this signature would mislead callers. To target
1291
+ // a feature branch, use uploadWikiFileToChangeset.
1292
+ const fd = new FormData();
1293
+ fd.append('path', params.path);
1294
+ fd.append('file', params.file);
1295
+ if (params.message) fd.append('message', params.message);
1296
+ return this.multipartRequest('POST', ENDPOINTS.WIKI_UPLOADS(orgId, wikiId), fd);
1297
+ }
1298
+
1299
+ /**
1300
+ * Upload a binary file into a changeset's feature branch (read scope +
1301
+ * author-only). Used by reader flow: propose a changeset, attach
1302
+ * binary, PATCH markdown that references it. On merge the binary
1303
+ * squashes into the default branch.
1304
+ */
1305
+ async uploadWikiFileToChangeset(
1306
+ orgId: string,
1307
+ wikiId: string,
1308
+ changesetId: string,
1309
+ params: { path: string; file: Blob; message?: string },
1310
+ ): Promise<WikiFileUploadResponse> {
1311
+ const fd = new FormData();
1312
+ fd.append('path', params.path);
1313
+ fd.append('file', params.file);
1314
+ if (params.message) fd.append('message', params.message);
1315
+ return this.multipartRequest(
1316
+ 'POST',
1317
+ ENDPOINTS.WIKI_CHANGESET_FILES(orgId, wikiId, changesetId),
1318
+ fd,
1319
+ );
1320
+ }
1321
+
1322
+ /** Remove a binary file from the default branch. Text files must use a
1323
+ * changeset delete action. The blob stays reachable via git history. */
1324
+ async deleteWikiFile(
1325
+ orgId: string,
1326
+ wikiId: string,
1327
+ params: { path: string; message?: string },
1328
+ ): Promise<void> {
1329
+ await this.request(
1330
+ 'DELETE',
1331
+ ENDPOINTS.WIKI_FILES(orgId, wikiId),
1332
+ undefined,
1333
+ { path: params.path, message: params.message },
1334
+ );
1335
+ }
1336
+
1337
+ /**
1338
+ * Sign a short-lived (~5-min) URL the browser can paste into a media
1339
+ * element src. ACL is evaluated here; the returned URL is a capability
1340
+ * token — don't leak it.
1341
+ */
1342
+ async getWikiFilePreviewUrl(
1343
+ orgId: string,
1344
+ wikiId: string,
1345
+ params: { path: string; ref?: string },
1346
+ ): Promise<WikiFilePreviewUrlResponse> {
1347
+ return this.request(
1348
+ 'POST',
1349
+ ENDPOINTS.WIKI_FILE_PREVIEW_URL(orgId, wikiId),
1350
+ params,
1351
+ );
1352
+ }
1353
+
1147
1354
  // ---- Wiki Path Scopes (AFCS ACL) ----
1148
1355
 
1149
1356
  async getWikiPathScopes(orgId: string, wikiId: string): Promise<WikiPathScope[]> {
@@ -1215,9 +1422,12 @@ export class ParallClient {
1215
1422
 
1216
1423
  // ---- Unified Comments ----
1217
1424
 
1218
- async getComments(orgId: string, params: {
1219
- target_uri: string; limit?: number; cursor?: string; order?: string;
1220
- }): Promise<PaginatedResponse<Comment>> {
1425
+ async getComments(
1426
+ orgId: string,
1427
+ params:
1428
+ | { target_uri: string; target_prefix?: never; limit?: number; cursor?: string; order?: string }
1429
+ | { target_prefix: string; target_uri?: never; limit?: number; cursor?: string; order?: string },
1430
+ ): Promise<PaginatedResponse<Comment>> {
1221
1431
  return this.request('GET', ENDPOINTS.COMMENTS(orgId), undefined, params);
1222
1432
  }
1223
1433
 
package/src/constants.ts CHANGED
@@ -11,6 +11,7 @@ export const ENDPOINTS = {
11
11
  AUTH_LOGIN: `${API_BASE}/auth/login`,
12
12
  AUTH_REFRESH: `${API_BASE}/auth/refresh`,
13
13
  AUTH_LOGOUT: `${API_BASE}/auth/logout`,
14
+ AUTH_CHANGE_PASSWORD: `${API_BASE}/auth/change-password`,
14
15
  AUTH_CHECK_EMAIL: `${API_BASE}/auth/check-email`,
15
16
  AUTH_VERIFY_EMAIL: `${API_BASE}/auth/verify-email`,
16
17
  AUTH_RESEND_CODE: `${API_BASE}/auth/resend-code`,
@@ -89,8 +90,6 @@ export const ENDPOINTS = {
89
90
  `${API_BASE}/orgs/${orgId}/agents/${agentId}/sessions/${sessionId}/steps`,
90
91
  AGENT_SESSION_STEP: (orgId: string, agentId: string, sessionId: string, stepId: string) =>
91
92
  `${API_BASE}/orgs/${orgId}/agents/${agentId}/sessions/${sessionId}/steps/${stepId}`,
92
- AGENT_SESSION_TASK_BACKFILL_COMMENTS: (orgId: string, agentId: string, sessionId: string) =>
93
- `${API_BASE}/orgs/${orgId}/agents/${agentId}/sessions/${sessionId}/task-backfill-comments`,
94
93
  AGENT_TASKS: (orgId: string, agentId: string) =>
95
94
  `${API_BASE}/orgs/${orgId}/agents/${agentId}/tasks`,
96
95
  AGENT_RUNTIME_AUTH: (orgId: string, agentId: string) =>
@@ -157,6 +156,15 @@ export const ENDPOINTS = {
157
156
  PROJECTS: (orgId: string) => `${API_BASE}/orgs/${orgId}/projects`,
158
157
  PROJECT: (orgId: string, projectId: string) => `${API_BASE}/orgs/${orgId}/projects/${projectId}`,
159
158
 
159
+ // Schedules (org-scoped, platform time trigger primitive)
160
+ SCHEDULES: (orgId: string) => `${API_BASE}/orgs/${orgId}/schedules`,
161
+ SCHEDULE: (orgId: string, id: string) => `${API_BASE}/orgs/${orgId}/schedules/${id}`,
162
+ SCHEDULE_PAUSE: (orgId: string, id: string) => `${API_BASE}/orgs/${orgId}/schedules/${id}/pause`,
163
+ SCHEDULE_RESUME: (orgId: string, id: string) => `${API_BASE}/orgs/${orgId}/schedules/${id}/resume`,
164
+ SCHEDULE_CANCEL: (orgId: string, id: string) => `${API_BASE}/orgs/${orgId}/schedules/${id}/cancel`,
165
+ SCHEDULE_RUNS: (orgId: string, id: string) => `${API_BASE}/orgs/${orgId}/schedules/${id}/runs`,
166
+ SCHEDULE_RUN: (orgId: string, runId: string) => `${API_BASE}/orgs/${orgId}/schedule_runs/${runId}`,
167
+
160
168
  // Invitations (org-scoped, admin)
161
169
  ORG_INVITATIONS: (orgId: string) => `${API_BASE}/orgs/${orgId}/invitations`,
162
170
  ORG_INVITATION: (orgId: string, invId: string) =>
@@ -180,6 +188,7 @@ export const ENDPOINTS = {
180
188
  WIKI_PAGE_INDEX: (orgId: string, wikiId: string) => `${WIKI_BASE}/orgs/${orgId}/wikis/${wikiId}/page-index`,
181
189
  WIKI_REFS: (orgId: string, wikiId: string) => `${WIKI_BASE}/orgs/${orgId}/wikis/${wikiId}/refs`,
182
190
  WIKI_REFS_CHECK: (orgId: string, wikiId: string) => `${WIKI_BASE}/orgs/${orgId}/wikis/${wikiId}/refs/check`,
191
+ WIKI_ANCHOR_STATUS: (orgId: string, wikiId: string) => `${WIKI_BASE}/orgs/${orgId}/wikis/${wikiId}/anchor-status`,
183
192
  WIKI_CHANGESETS: (orgId: string, wikiId: string) => `${WIKI_BASE}/orgs/${orgId}/wikis/${wikiId}/changesets`,
184
193
  WIKI_CHANGESET: (orgId: string, wikiId: string, changesetId: string) =>
185
194
  `${WIKI_BASE}/orgs/${orgId}/wikis/${wikiId}/changesets/${changesetId}`,
@@ -189,6 +198,16 @@ export const ENDPOINTS = {
189
198
  `${WIKI_BASE}/orgs/${orgId}/wikis/${wikiId}/changesets/${changesetId}/merge`,
190
199
  WIKI_CHANGESET_CLOSE: (orgId: string, wikiId: string, changesetId: string) =>
191
200
  `${WIKI_BASE}/orgs/${orgId}/wikis/${wikiId}/changesets/${changesetId}/close`,
201
+ WIKI_CHANGESET_FILES: (orgId: string, wikiId: string, changesetId: string) =>
202
+ `${WIKI_BASE}/orgs/${orgId}/wikis/${wikiId}/changesets/${changesetId}/files`,
203
+
204
+ // Wiki Binary Upload / Delete (Phase 2 of wiki-file-storage-design)
205
+ WIKI_UPLOADS: (orgId: string, wikiId: string) =>
206
+ `${WIKI_BASE}/orgs/${orgId}/wikis/${wikiId}/uploads`,
207
+ WIKI_FILES: (orgId: string, wikiId: string) =>
208
+ `${WIKI_BASE}/orgs/${orgId}/wikis/${wikiId}/files`,
209
+ WIKI_FILE_PREVIEW_URL: (orgId: string, wikiId: string) =>
210
+ `${WIKI_BASE}/orgs/${orgId}/wikis/${wikiId}/files/preview-url`,
192
211
 
193
212
  // Wiki Path Scopes (AFCS ACL)
194
213
  WIKI_PATH_SCOPES: (orgId: string, wikiId: string) =>
@@ -310,11 +329,85 @@ export const WS_EVENTS = {
310
329
  INBOX_BULK_UPDATE: 'inbox.bulk_update',
311
330
  READ_POSITION_UPDATED: 'read_position.updated',
312
331
  DISPATCH_NEW: 'dispatch.new',
332
+ SCHEDULE_CREATED: 'schedule.created',
333
+ SCHEDULE_UPDATED: 'schedule.updated',
334
+ SCHEDULE_DELETED: 'schedule.deleted',
335
+ SCHEDULE_FIRED: 'schedule.fired',
313
336
  } as const;
314
337
 
315
338
  // Canonical target URI builders for unified comments.
339
+ //
340
+ // Heading and line anchors for wiki inline comments MUST be pinned to a full
341
+ // commit SHA (40-hex-char). The builders do not validate — callers are
342
+ // expected to pass the current wiki HEAD at comment-creation time.
343
+
344
+ // encodeHeadingSegment mirrors Go's url.PathEscape + ':' → '%3A' exactly so
345
+ // the Go and TS SDKs produce byte-identical target_uri values for the same
346
+ // heading text. encodeURIComponent differs from PathEscape on a handful of
347
+ // characters — this function normalizes back to PathEscape's character set:
348
+ // - unescape & = @ $ + (PathEscape leaves these alone, URIComponent encodes)
349
+ // - escape ! * ' ( ) (URIComponent leaves these alone, PathEscape encodes)
350
+ // ':' is already encoded as %3A by encodeURIComponent.
351
+ function encodeHeadingSegment(seg: string): string {
352
+ return encodeURIComponent(seg)
353
+ .replace(/%26/g, '&')
354
+ .replace(/%3D/g, '=')
355
+ .replace(/%40/g, '@')
356
+ .replace(/%24/g, '$')
357
+ .replace(/%2B/g, '+')
358
+ .replace(/!/g, '%21')
359
+ .replace(/\*/g, '%2A')
360
+ .replace(/'/g, '%27')
361
+ .replace(/\(/g, '%28')
362
+ .replace(/\)/g, '%29');
363
+ }
364
+
365
+ // encodePathSegment mirrors Go's url.URL path encoding ("encodePath" mode) for
366
+ // one segment (characters between '/'). Go leaves alphanumerics, -_.~, and the
367
+ // specific sub-delims $ & + , : ; = @ alone; it percent-encodes everything else
368
+ // — importantly including ! ' ( ) * which encodeURIComponent passes through.
369
+ // Keeping these in sync is required so wikiPage / wikiHeading / wikiLineRange
370
+ // produce byte-identical target_uri across Go and TS SDKs for any legal
371
+ // filename (including ones with ', (, ), !, *, space, #, ?, %, etc.).
372
+ function encodePathSegment(seg: string): string {
373
+ return encodeURIComponent(seg)
374
+ // Un-escape what Go's encodePath passes through
375
+ .replace(/%24/g, '$')
376
+ .replace(/%26/g, '&')
377
+ .replace(/%2B/g, '+')
378
+ .replace(/%2C/g, ',')
379
+ .replace(/%3A/g, ':')
380
+ .replace(/%3B/g, ';')
381
+ .replace(/%3D/g, '=')
382
+ .replace(/%40/g, '@')
383
+ // Encode what Go escapes but encodeURIComponent leaves raw
384
+ .replace(/!/g, '%21')
385
+ .replace(/'/g, '%27')
386
+ .replace(/\(/g, '%28')
387
+ .replace(/\)/g, '%29')
388
+ .replace(/\*/g, '%2A');
389
+ }
390
+
391
+ function encodeWikiPath(path: string): string {
392
+ return path
393
+ .replace(/^\//, '')
394
+ .split('/')
395
+ .map(encodePathSegment)
396
+ .join('/');
397
+ }
398
+
316
399
  export const COMMENT_TARGET = {
317
400
  task: (taskId: string) => `prll://${taskId}`,
318
401
  changeset: (csId: string, wikiId: string) => `prll://${csId}?wiki=${wikiId}`,
319
- wikiPage: (wikiId: string, path: string) => `prll://${wikiId}/${path.replace(/^\//, '')}`,
402
+ wikiPage: (wikiId: string, path: string) => `prll://${wikiId}/${encodeWikiPath(path)}`,
403
+ // Hierarchy encoded as "::"-joined PathEscape segments with ':' → '%3A' so a
404
+ // literal ':' in a heading cannot be confused with the separator.
405
+ wikiHeading: (wikiId: string, path: string, rev: string, headingSegments: string[]) => {
406
+ const encoded = headingSegments.map(encodeHeadingSegment).join('::');
407
+ return `prll://${wikiId}/${encodeWikiPath(path)}?rev=${rev}#h=${encoded}`;
408
+ },
409
+ wikiLineRange: (wikiId: string, path: string, rev: string, lineStart: number, lineEnd: number) => {
410
+ const frag = lineStart === lineEnd ? `l=${lineStart}` : `l=${lineStart}-${lineEnd}`;
411
+ return `prll://${wikiId}/${encodeWikiPath(path)}?rev=${rev}#${frag}`;
412
+ },
320
413
  } as const;