@parall/cli 1.52.0 → 1.52.2

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.
@@ -1 +1 @@
1
- {"version":3,"file":"clip.d.ts","sourceRoot":"","sources":["../../src/commands/clip.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;AAgCpC,wBAAgB,oBAAoB,CAAC,OAAO,EAAE,OAAO,QAiSpD"}
1
+ {"version":3,"file":"clip.d.ts","sourceRoot":"","sources":["../../src/commands/clip.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;AAiFpC,wBAAgB,oBAAoB,CAAC,OAAO,EAAE,OAAO,QA2XpD"}
@@ -1,53 +1,97 @@
1
1
  import { resolveCredentials } from '../lib/client.js';
2
- import { invokeClipAwaitingActivation } from '../lib/clip-invoke.js';
3
2
  import { execEdgeClipWithBoundedWait } from '../lib/edge-exec.js';
4
3
  import { printJson, printError, parsePositiveInt } from '../lib/output.js';
5
- /**
6
- * Derive a CLI-friendly alias from a registry source string.
7
- * Handles scoped packages ("@scope/name@1.0.0" → "name") and
8
- * plain packages ("name@1.0.0" "name").
9
- */
10
- function deriveAlias(source) {
11
- let s = source.trim();
12
- if (s.startsWith('@')) {
13
- const slashIdx = s.indexOf('/');
14
- if (slashIdx > 0) {
15
- const rest = s.slice(slashIdx + 1);
16
- const atIdx = rest.indexOf('@');
17
- s = atIdx > 0 ? rest.slice(0, atIdx) : rest;
4
+ import { buildPublishedManifest, collectPublishSource } from '../lib/publish-source.js';
5
+ const NO_ALIAS_HINT = 'no alias reference this connection by its ccn_ id, or ask a human to name it in the Clip Console so callers can tell the accounts apart';
6
+ function connectionAnnotations(conn, deviceById) {
7
+ const out = { target: 'orphaned' };
8
+ if (conn.mcp_config_id) {
9
+ out.target = 'mcp';
10
+ }
11
+ else if (conn.device_id) {
12
+ const device = deviceById?.get(conn.device_id);
13
+ if (!device) {
14
+ // Absent row deleted device: the device list may have failed to load
15
+ // (deviceById null) or lag a concurrent delete. 'orphaned' is reserved
16
+ // for the provable case (device_id itself nulled by the server).
17
+ out.target = 'device';
18
+ }
19
+ else if (device.placement === 'hosted') {
20
+ out.target = 'cloud';
21
+ out.device = device.name;
18
22
  }
23
+ else {
24
+ out.target = 'desktop';
25
+ out.device = device.name;
26
+ out.device_status = device.status;
27
+ }
28
+ }
29
+ if (!conn.alias)
30
+ out.hint = NO_ALIAS_HINT;
31
+ return out;
32
+ }
33
+ /** Compact connection form for discovery output (`clip list`). */
34
+ function summarizeConnection(conn, deviceById) {
35
+ return {
36
+ id: conn.id,
37
+ alias: conn.alias ?? null,
38
+ ...(conn.profile ? { profile: conn.profile } : {}),
39
+ ...connectionAnnotations(conn, deviceById),
40
+ };
41
+ }
42
+ /** Device index for annotating connections; null = list unavailable (degrade
43
+ * to target 'device' rather than failing discovery or guessing placement). */
44
+ async function loadDeviceIndex(client, orgId) {
45
+ try {
46
+ const resp = await client.listEdgeDevices(orgId);
47
+ return new Map((resp.data ?? []).map((d) => [d.id, d]));
19
48
  }
20
- else {
21
- const atIdx = s.indexOf('@');
22
- if (atIdx > 0)
23
- s = s.slice(0, atIdx);
49
+ catch {
50
+ return null;
24
51
  }
25
- // Normalize to server's allowed alias charset: ^[a-z0-9][a-z0-9-_]*$
26
- return s
27
- .toLowerCase()
28
- .replace(/[^a-z0-9-_]/g, '-')
29
- .replace(/^[^a-z0-9]+/, '');
30
52
  }
31
53
  export function registerClipCommands(program) {
32
- const clip = program.command('clip').description('Manage clips');
54
+ const clip = program
55
+ .command('clip')
56
+ .description('Discover, install and execute clips installed in the organization');
33
57
  clip
34
58
  .command('list')
35
- .description('List clips in the organization')
59
+ .description('List clips installed in the organization, each with its connections — the --connection refs `clip exec` takes')
36
60
  .action(async () => {
37
61
  try {
38
62
  const { client, orgId } = resolveCredentials();
39
- const clips = await client.listClips(orgId);
40
- // Table display: alias, name, version, status, source_type
63
+ // Reads the v3 registry installs. The legacy listClips() reads the v2
64
+ // `clips` table, whose machine-bound registry installs are retired — a
65
+ // clip installed through the Console never appeared there, which is why
66
+ // this command used to under-report.
67
+ const clips = await client.listInstalledRegistryClips(orgId);
41
68
  if (clips.length === 0) {
42
- printJson({ data: [], message: 'No clips found' });
69
+ printJson({ data: [], message: 'No clips installed' });
43
70
  return;
44
71
  }
45
- const rows = clips.map((c) => ({
46
- alias: c.alias,
47
- name: c.name,
48
- version: c.version ?? '-',
49
- status: c.status,
50
- source_type: c.source_type,
72
+ const deviceById = await loadDeviceIndex(client, orgId);
73
+ // One list answers both discovery questions — WHICH clip and WHERE it
74
+ // can run — so an agent resolves "call <clip> for me" in a single
75
+ // command instead of a per-clip connections crawl.
76
+ const rows = await Promise.all(clips.map(async (c) => {
77
+ const row = {
78
+ id: c.id,
79
+ name: c.name,
80
+ ...(c.description ? { description: c.description } : {}),
81
+ version: c.version ?? '-',
82
+ visibility: c.visibility,
83
+ };
84
+ try {
85
+ const resp = await client.listClipConnections(orgId, c.id);
86
+ row.connections = (resp.data ?? []).map((conn) => summarizeConnection(conn, deviceById));
87
+ }
88
+ catch (err) {
89
+ // One clip's broken connections read must not take down
90
+ // discovery of every other clip — degrade that row, honestly.
91
+ row.connections = null;
92
+ row.connections_error = err instanceof Error ? err.message : String(err);
93
+ }
94
+ return row;
51
95
  }));
52
96
  printJson(rows);
53
97
  }
@@ -57,23 +101,25 @@ export function registerClipCommands(program) {
57
101
  });
58
102
  clip
59
103
  .command('install')
60
- .description('Install a clip from a registry source')
61
- .argument('<source>', 'Registry source identifier (used as name and alias)')
62
- .option('--alias <alias>', 'Override the alias (default: derived from source)')
63
- .requiredOption('-m, --machine-id <machineId>', 'Machine ID that runs this clip')
64
- .action(async (source, opts) => {
104
+ .description('Install a registry clip into the organization')
105
+ .argument('<clip>', 'Registry clip id (crg_...) or name')
106
+ .action(async (clip) => {
65
107
  try {
66
108
  const { client, orgId } = resolveCredentials();
67
- // Derive alias: take the last segment, replace non-alphanumeric with dash, lowercase.
68
- const alias = opts.alias ?? deriveAlias(source);
69
- const req = {
70
- alias,
71
- source_type: 'registry',
72
- source_ref: source,
73
- machine_id: opts.machineId,
74
- };
75
- const clip = await client.createClip(orgId, req);
76
- printJson(clip);
109
+ // v3 installs a REFERENCE into clip_installs, keyed by the registry id.
110
+ // There is no machine to pick: a v3 clip runs wherever the connection
111
+ // it is bound to points, which is why the old --machine-id is gone.
112
+ // listOrgRegistryClips is the surface carrying `crg_` ids — the Pinix
113
+ // catalog proxy does not.
114
+ const registry = await client.listOrgRegistryClips(orgId);
115
+ const found = registry.find((c) => c.id === clip || c.name === clip);
116
+ if (!found) {
117
+ printError(new Error(`No registry clip matching "${clip}". Pass a crg_ id or the exact registry name; a private clip from another org is not installable.`));
118
+ return;
119
+ }
120
+ // Idempotent server-side: installing an already-installed clip is a no-op.
121
+ const resp = await client.installRegistryClip(orgId, found.id);
122
+ printJson({ ...resp, id: found.id, name: found.name });
77
123
  }
78
124
  catch (err) {
79
125
  printError(err);
@@ -81,29 +127,65 @@ export function registerClipCommands(program) {
81
127
  });
82
128
  clip
83
129
  .command('info')
84
- .description('Show detailed information about a clip by alias')
85
- .argument('<alias>', 'Clip alias')
86
- .action(async (alias) => {
130
+ .description("Show detailed clip information, including its manifest commands and params. For your org's OWN clips this includes review_status/review_note (the platform-review state of a public publish)")
131
+ .argument('<clip>', 'Registry clip id (crg_...) or name')
132
+ .action(async (clip) => {
87
133
  try {
88
134
  const { client, orgId } = resolveCredentials();
89
- const clips = await client.listClips(orgId);
90
- const found = clips.find((c) => c.alias === alias);
135
+ // Resolve installed-first (the historical semantic), then fall back to
136
+ // the org registry: an author checking a clip they published but never
137
+ // installed — the publish→review→revise loop — must not dead-end here.
138
+ const installed = await client.listInstalledRegistryClips(orgId);
139
+ let found = installed.find((c) => c.id === clip || c.name === clip);
91
140
  if (!found) {
92
- printError(new Error(`Clip with alias "${alias}" not found`));
141
+ const registry = await client.listOrgRegistryClips(orgId);
142
+ found = registry.find((c) => c.id === clip || c.name === clip);
143
+ }
144
+ let entry = found;
145
+ if (found) {
146
+ // The detail read is what carries review_status/review_note (server
147
+ // attaches them only for the owning org). A cross-org entry can lose
148
+ // detail visibility between the list and this read — the list entry
149
+ // is still correct data, so fall back to it rather than failing info.
150
+ try {
151
+ entry = await client.getRegistryClip(orgId, found.id);
152
+ }
153
+ catch {
154
+ entry = found;
155
+ }
156
+ }
157
+ else if (clip.startsWith('crg_')) {
158
+ // Both lists are single pages (registry capped at 100 entries), so a
159
+ // valid id can sit beyond them — an exact crg_ id needs no list
160
+ // resolution at all; go straight to the detail endpoint before
161
+ // concluding not-found.
162
+ try {
163
+ entry = await client.getRegistryClip(orgId, clip);
164
+ }
165
+ catch {
166
+ // fall through to the not-found error below
167
+ }
168
+ }
169
+ if (!entry) {
170
+ // `return` matters: without it the lookup falls through and dies
171
+ // with a TypeError instead of the message just printed.
172
+ printError(new Error(`No clip matching "${clip}" (see \`parall clip list\`)`));
173
+ return;
93
174
  }
94
- // Display full details including manifest commands
95
175
  const info = {
96
- id: found.id,
97
- alias: found.alias,
98
- name: found.name,
99
- version: found.version,
100
- description: found.description,
101
- status: found.status,
102
- source_type: found.source_type,
103
- source_ref: found.source_ref,
104
- manifest: found.manifest,
105
- created_at: found.created_at,
106
- updated_at: found.updated_at,
176
+ id: entry.id,
177
+ name: entry.name,
178
+ version: entry.version,
179
+ description: entry.description,
180
+ visibility: entry.visibility,
181
+ manifest: entry.manifest,
182
+ created_at: entry.created_at,
183
+ updated_at: entry.updated_at,
184
+ // Approved pointer = the snapshot cross-org installs serve; absent =
185
+ // never approved. review_* appear only for this org's own clips.
186
+ ...(entry.approved_version_id ? { approved_version_id: entry.approved_version_id } : {}),
187
+ ...(entry.review_status ? { review_status: entry.review_status } : {}),
188
+ ...(entry.review_note ? { review_note: entry.review_note } : {}),
107
189
  };
108
190
  printJson(info);
109
191
  }
@@ -113,22 +195,30 @@ export function registerClipCommands(program) {
113
195
  });
114
196
  clip
115
197
  .command('connections')
116
- .description('List clip connections (Edge device/profile or MCP bindings) for a clip by alias')
117
- .argument('<alias>', 'Clip alias')
118
- .action(async (alias) => {
198
+ .description("List a clip's connections in full — each row's ccn_ id or alias is what `clip exec --connection` takes")
199
+ .argument('<clip>', 'Registry clip id (crg_...) or name')
200
+ .action(async (clip) => {
119
201
  try {
120
202
  const { client, orgId } = resolveCredentials();
121
- // Resolve alias to clip ID same lookup `clip info` uses
122
- const clips = await client.listClips(orgId);
123
- const found = clips.find((c) => c.alias === alias);
203
+ // clip_connections.clip_id is a foreign key onto clip_registry, so this
204
+ // endpoint is keyed by the REGISTRY id. Resolving through the legacy
205
+ // listClips() handed it a `clp_` id from the v2 table, which matches
206
+ // nothing — the command returned an empty list for every clip.
207
+ const installed = await client.listInstalledRegistryClips(orgId);
208
+ const found = installed.find((c) => c.id === clip || c.name === clip);
124
209
  if (!found) {
125
- printError(new Error(`Clip with alias "${alias}" not found`));
210
+ printError(new Error(`No installed clip matching "${clip}" (pass a crg_ id or the registry name; see \`parall clip list\`)`));
211
+ return;
126
212
  }
127
213
  const resp = await client.listClipConnections(orgId, found.id);
128
214
  // A Go nil slice serializes as `null`, not `[]` — normalize so an
129
215
  // empty result always prints `[]` (ts/AGENTS.md § Cross-Language
130
216
  // Contract Edge Cases).
131
- printJson(resp.data ?? []);
217
+ const conns = resp.data ?? [];
218
+ const deviceById = conns.some((c) => c.device_id)
219
+ ? await loadDeviceIndex(client, orgId)
220
+ : new Map();
221
+ printJson(conns.map((c) => ({ ...c, ...connectionAnnotations(c, deviceById) })));
132
222
  }
133
223
  catch (err) {
134
224
  printError(err);
@@ -136,16 +226,17 @@ export function registerClipCommands(program) {
136
226
  });
137
227
  clip
138
228
  .command('tools')
139
- .description('List the MCP tool schemas (name, description, inputSchema) of an MCP clip by alias — the discovery step before `clip exec <clip> <tool>`')
140
- .argument('<alias>', 'Clip alias')
141
- .action(async (alias) => {
229
+ .description('List the MCP tool schemas (name, description, inputSchema) of an MCP clip — the discovery step before `clip exec <clip> <tool>`')
230
+ .argument('<clip>', 'Registry clip id (crg_...) or name')
231
+ .action(async (clip) => {
142
232
  try {
143
233
  const { client, orgId } = resolveCredentials();
144
- // Resolve alias to clip ID — same lookup `clip connections`/`info` use.
145
- const clips = await client.listClips(orgId);
146
- const found = clips.find((c) => c.alias === alias);
234
+ // Same lookup `clip connections`/`info` use.
235
+ const installed = await client.listInstalledRegistryClips(orgId);
236
+ const found = installed.find((c) => c.id === clip || c.name === clip);
147
237
  if (!found) {
148
- printError(new Error(`Clip with alias "${alias}" not found`));
238
+ printError(new Error(`No installed clip matching "${clip}" (see \`parall clip list\`)`));
239
+ return;
149
240
  }
150
241
  // MCP tools are NOT frozen in the clip manifest, so `clip info` cannot
151
242
  // show them; the live server snapshot behind getClipMCPConfig is the
@@ -160,62 +251,18 @@ export function registerClipCommands(program) {
160
251
  printError(err);
161
252
  }
162
253
  });
163
- clip
164
- .command('invoke')
165
- .description('Invoke a clip command')
166
- .argument('<alias>', 'Clip alias')
167
- .argument('<command>', 'Command name to invoke')
168
- .argument('[input]', 'Input data (JSON string or plain text)')
169
- .option('--timeout <ms>', 'Timeout in milliseconds', '30000')
170
- .action(async (alias, command, input, opts) => {
171
- try {
172
- const { client, orgId } = resolveCredentials();
173
- // Parse input: try JSON first, fall back to plain text wrapping.
174
- let parsedInput = undefined;
175
- if (input !== undefined) {
176
- try {
177
- parsedInput = JSON.parse(input);
178
- }
179
- catch {
180
- // Not valid JSON — send as a plain string.
181
- parsedInput = input;
182
- }
183
- }
184
- let timeoutMs;
185
- if (opts?.timeout) {
186
- timeoutMs = parseInt(opts.timeout, 10);
187
- if (isNaN(timeoutMs) || timeoutMs <= 0) {
188
- printError(new Error('--timeout must be a positive integer (milliseconds)'));
189
- }
190
- }
191
- const req = {
192
- alias,
193
- command,
194
- input: parsedInput,
195
- timeout_ms: timeoutMs,
196
- };
197
- // Hosted-browser clips may hit a cold profile: absorb the (pre-dispatch,
198
- // side-effect-free) BROWSER_PROFILE_ACTIVATING window here instead of
199
- // making every agent hand-roll a retry loop. See lib/clip-invoke.ts.
200
- const result = await invokeClipAwaitingActivation(client, orgId, req);
201
- if (result.error) {
202
- printError(new Error(result.error));
203
- }
204
- else {
205
- printJson(result.output);
206
- }
207
- }
208
- catch (err) {
209
- printError(err);
210
- }
211
- });
254
+ // `clip invoke` (the v2 clip-service path) is deliberately GONE, not hidden:
255
+ // discovery above lists v3 registry installs only, and a v3 name fed to the
256
+ // v2 invoke endpoint answered CLIP_NOT_FOUND — a dead verb that only misled
257
+ // callers into the wrong invocation path. `clip exec` is the one execution
258
+ // verb; the server endpoint retires separately with the rest of v2.
212
259
  clip
213
260
  .command('exec')
214
261
  .description('Execute a registry (Edge) clip command on an Edge device. A cloud (hosted) profile is reachable ONLY via --connection; with neither --connection nor --edge, the server resolves just your OWN online desktop device (legacy BYOC fallback — never a cloud profile)')
215
262
  .argument('<clip>', 'Clip name in the org clip registry')
216
263
  .argument('<command>', 'Command name to execute')
217
264
  .argument('[args]', 'Command arguments (JSON string or plain text)')
218
- .option('--connection <ref>', 'Clip connection id (ccn_…) or alias. REQUIRED to reach a cloud (hosted) profile the binding its maintainer created is the authorization')
265
+ .option('--connection <ref>', 'Clip connection id (ccn_…) or alias — discover them with `parall clip connections <clip>`. REQUIRED to reach a cloud (hosted) profile: the binding its maintainer created is the authorization')
219
266
  .option('--edge <edgeId>', 'A desktop (BYOC) device you own. Mutually exclusive with --connection')
220
267
  .option('--profile <name>', 'Browser profile (with --connection it may only restate the granted one)')
221
268
  .option('--timeout <ms>', 'Execution timeout in milliseconds', '30000')
@@ -271,21 +318,69 @@ export function registerClipCommands(program) {
271
318
  printError(err);
272
319
  }
273
320
  });
321
+ clip
322
+ .command('publish')
323
+ .description('Publish a clip directory (manifest.json or site.json + its top-level .js files) into the org registry. Re-publishing an existing name is author-only and replaces the file set; "visibility": "public" additionally submits the version for platform review before it can spread cross-org')
324
+ .argument('<directory>', 'Clip source directory')
325
+ .action(async (directory) => {
326
+ try {
327
+ const { client, orgId } = resolveCredentials();
328
+ const { manifest, files, directoryName } = collectPublishSource(directory);
329
+ // Same request derivation as the Edge CLI (`parall-clip publish`):
330
+ // manifest fields win, directory name / 0.0.1 / private fill the gaps.
331
+ // Send the TRIMMED values — name is the org-wide upsert key, so
332
+ // `" google "` must not mint an entry distinct from `google`.
333
+ const manifestName = typeof manifest.name === 'string' ? manifest.name.trim() : '';
334
+ const manifestVersion = typeof manifest.version === 'string' ? manifest.version.trim() : '';
335
+ const entry = await client.publishRegistryClip(orgId, {
336
+ name: manifestName !== '' ? manifestName : directoryName,
337
+ description: typeof manifest.description === 'string' ? manifest.description : undefined,
338
+ version: manifestVersion !== '' ? manifestVersion : '0.0.1',
339
+ visibility: manifest.visibility === 'public' ? 'public' : 'private',
340
+ manifest: buildPublishedManifest(manifest),
341
+ files,
342
+ });
343
+ printJson({
344
+ id: entry.id,
345
+ name: entry.name,
346
+ version: entry.version ?? null,
347
+ visibility: entry.visibility,
348
+ files: Object.keys(files).sort(),
349
+ // Present only when a public publish queued a review submission.
350
+ ...(entry.review_status ? { review_status: entry.review_status } : {}),
351
+ });
352
+ if (entry.review_status === 'pending') {
353
+ // stderr, keeping stdout parseable — same convention as printRefHint.
354
+ // A public publish is NOT cross-org visible until platform review
355
+ // approves; tell the publisher where to watch that state.
356
+ console.error(`Review pending: this clip stays invisible to other orgs until platform review approves it (your own org can use it immediately). ` +
357
+ `Check status with \`parall clip info ${entry.name}\` (review_status / review_note) ` +
358
+ `or GET /api/v1/orgs/${orgId}/clip-registry/${entry.id}.`);
359
+ }
360
+ }
361
+ catch (err) {
362
+ printError(err);
363
+ }
364
+ });
274
365
  clip
275
366
  .command('remove')
276
- .description('Remove a clip by alias')
277
- .argument('<alias>', 'Clip alias')
278
- .action(async (alias) => {
367
+ .description("Remove the organization's install of a clip")
368
+ .argument('<clip>', 'Registry clip id (crg_...) or name')
369
+ .action(async (clip) => {
279
370
  try {
280
371
  const { client, orgId } = resolveCredentials();
281
- // Resolve alias to clip ID
282
- const clips = await client.listClips(orgId);
283
- const found = clips.find((c) => c.alias === alias);
372
+ // Uninstall drops the org's reference in clip_installs. The v2
373
+ // deleteClip() operates on a different table and cannot reach a clip
374
+ // installed through the registry it would report "not found" for one
375
+ // that plainly shows up in `clip list`.
376
+ const installed = await client.listInstalledRegistryClips(orgId);
377
+ const found = installed.find((c) => c.id === clip || c.name === clip);
284
378
  if (!found) {
285
- printError(new Error(`Clip with alias "${alias}" not found`));
379
+ printError(new Error(`No installed clip matching "${clip}" (see \`parall clip list\`)`));
380
+ return;
286
381
  }
287
- await client.deleteClip(orgId, found.id);
288
- printJson({ ok: true, removed: alias });
382
+ await client.uninstallRegistryClip(orgId, found.id);
383
+ printJson({ ok: true, removed: found.name, id: found.id });
289
384
  }
290
385
  catch (err) {
291
386
  printError(err);
@@ -1,3 +1,33 @@
1
1
  import type { Command } from 'commander';
2
+ /**
3
+ * Message ↔ session/step attribution for a send. Lane context WINS over the
4
+ * process-level runtime context: the lane file is written per dispatch (a
5
+ * fork turn carries its child ase_/step), while PRLL_CONTEXT_FILE on
6
+ * single-process runtimes (codex app-server) is pinned to the MAIN session
7
+ * for the process's whole life — letting it win stamps fork replies and
8
+ * their dispatch rows with main's session. Runtime context only fills what
9
+ * the lane context does not provide.
10
+ *
11
+ * session and step are resolved as a PAIR, never independently: the server
12
+ * validates agent_step_id first and derives agent_session_id FROM that step,
13
+ * ignoring the supplied session whenever the step is valid (its check is
14
+ * chat-level, so a main-session step passes for a same-chat fork); when the
15
+ * step is stale it falls back to the supplied session instead. Either half
16
+ * can therefore decide the attribution, so a pair mixed from two sources has
17
+ * no single correct reading. The lane file writes session_id and step_id
18
+ * independently and may carry either alone — so once the lane supplies ANY
19
+ * attribution it owns both fields, and the runtime fills a gap only when it
20
+ * demonstrably describes the same session the lane names.
21
+ */
22
+ export declare function resolveSendAttribution(ctx: {
23
+ sessionId?: string;
24
+ stepId?: string;
25
+ }, laneCtx: {
26
+ sessionId?: string;
27
+ stepId?: string;
28
+ } | null): {
29
+ sessionId?: string;
30
+ stepId?: string;
31
+ };
2
32
  export declare function registerMessageCommands(program: Command): void;
3
33
  //# sourceMappingURL=messages.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"messages.d.ts","sourceRoot":"","sources":["../../src/commands/messages.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;AAmBzC,wBAAgB,uBAAuB,CAAC,OAAO,EAAE,OAAO,QAsPvD"}
1
+ {"version":3,"file":"messages.d.ts","sourceRoot":"","sources":["../../src/commands/messages.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;AAmBzC;;;;;;;;;;;;;;;;;;;GAmBG;AACH,wBAAgB,sBAAsB,CACpC,GAAG,EAAE;IAAE,SAAS,CAAC,EAAE,MAAM,CAAC;IAAC,MAAM,CAAC,EAAE,MAAM,CAAA;CAAE,EAC5C,OAAO,EAAE;IAAE,SAAS,CAAC,EAAE,MAAM,CAAC;IAAC,MAAM,CAAC,EAAE,MAAM,CAAA;CAAE,GAAG,IAAI,GACtD;IAAE,SAAS,CAAC,EAAE,MAAM,CAAC;IAAC,MAAM,CAAC,EAAE,MAAM,CAAA;CAAE,CASzC;AAED,wBAAgB,uBAAuB,CAAC,OAAO,EAAE,OAAO,QAoPvD"}
@@ -4,6 +4,36 @@ import { LaneContextError, markLaneReplyCommitted, resolveCredentials, resolveLa
4
4
  import { printError, printJson, printRefHint, stripPrllScheme } from '../lib/output.js';
5
5
  import { NO_BODY_ERROR, resolveMessageText, TEXT_FILE_OPTION_DESC, TEXT_OPTION_DESC, } from '../lib/text-input.js';
6
6
  import { uploadFile } from '../lib/upload.js';
7
+ /**
8
+ * Message ↔ session/step attribution for a send. Lane context WINS over the
9
+ * process-level runtime context: the lane file is written per dispatch (a
10
+ * fork turn carries its child ase_/step), while PRLL_CONTEXT_FILE on
11
+ * single-process runtimes (codex app-server) is pinned to the MAIN session
12
+ * for the process's whole life — letting it win stamps fork replies and
13
+ * their dispatch rows with main's session. Runtime context only fills what
14
+ * the lane context does not provide.
15
+ *
16
+ * session and step are resolved as a PAIR, never independently: the server
17
+ * validates agent_step_id first and derives agent_session_id FROM that step,
18
+ * ignoring the supplied session whenever the step is valid (its check is
19
+ * chat-level, so a main-session step passes for a same-chat fork); when the
20
+ * step is stale it falls back to the supplied session instead. Either half
21
+ * can therefore decide the attribution, so a pair mixed from two sources has
22
+ * no single correct reading. The lane file writes session_id and step_id
23
+ * independently and may carry either alone — so once the lane supplies ANY
24
+ * attribution it owns both fields, and the runtime fills a gap only when it
25
+ * demonstrably describes the same session the lane names.
26
+ */
27
+ export function resolveSendAttribution(ctx, laneCtx) {
28
+ if (!laneCtx?.sessionId && !laneCtx?.stepId) {
29
+ return { sessionId: ctx.sessionId, stepId: ctx.stepId };
30
+ }
31
+ const sameSession = laneCtx.sessionId !== undefined && laneCtx.sessionId === ctx.sessionId;
32
+ return {
33
+ sessionId: laneCtx.sessionId,
34
+ stepId: laneCtx.stepId ?? (sameSession ? ctx.stepId : undefined),
35
+ };
36
+ }
7
37
  export function registerMessageCommands(program) {
8
38
  const messages = program.command('messages').description('Manage messages');
9
39
  messages
@@ -129,10 +159,6 @@ export function registerMessageCommands(program) {
129
159
  req.thread_root_id = threadRootId;
130
160
  if (opts.reply === false)
131
161
  req.hints = { no_reply: true };
132
- if (ctx.stepId)
133
- req.agent_step_id = ctx.stepId;
134
- if (ctx.sessionId)
135
- req.agent_session_id = ctx.sessionId;
136
162
  // Dispatch lane binding (PRLL_CONTEXT_DIR contract): a send whose
137
163
  // exact (chat, thread) target has an active lane context rides the
138
164
  // server's dispatch ledger — the lane token authorizes the write,
@@ -143,6 +169,11 @@ export function registerMessageCommands(program) {
143
169
  // per-invocation idempotency key. All of this stays out of the
144
170
  // model's view.
145
171
  const laneCtx = resolveLaneDispatchContext(chatId, threadRootId);
172
+ const attribution = resolveSendAttribution(ctx, laneCtx);
173
+ if (attribution.stepId)
174
+ req.agent_step_id = attribution.stepId;
175
+ if (attribution.sessionId)
176
+ req.agent_session_id = attribution.sessionId;
146
177
  let usedReplyKey = false;
147
178
  if (laneCtx) {
148
179
  req.dispatch_lane = laneCtx.lane;
@@ -153,10 +184,6 @@ export function registerMessageCommands(program) {
153
184
  else {
154
185
  req.idempotency_key = `cli:${randomUUID()}`;
155
186
  }
156
- if (!req.agent_step_id && laneCtx.stepId)
157
- req.agent_step_id = laneCtx.stepId;
158
- if (!req.agent_session_id && laneCtx.sessionId)
159
- req.agent_session_id = laneCtx.sessionId;
160
187
  }
161
188
  else {
162
189
  // By-source dispatch binding (parel — PRLL_DISPATCH_SOURCE_* env,
@@ -0,0 +1,12 @@
1
+ import { Command } from 'commander';
2
+ /**
3
+ * `parall wechat …` — the per-vendor platform verb for the personal-WeChat
4
+ * protocol channel (wechatapi.net, tier B in the multi-channel
5
+ * architecture). The agent sends as the connected WeChat account itself;
6
+ * the vendor token + device id never reach the runtime — api-server
7
+ * performs the vendor call in-process.
8
+ * INTERNAL research preview: the capability is flag-gated to the internal
9
+ * org. Design: docs/engineering-design/wechat-channel-design.md §3.
10
+ */
11
+ export declare function registerWechatCommands(program: Command): void;
12
+ //# sourceMappingURL=wechat.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"wechat.d.ts","sourceRoot":"","sources":["../../src/commands/wechat.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;AAIpC;;;;;;;;GAQG;AACH,wBAAgB,sBAAsB,CAAC,OAAO,EAAE,OAAO,QA6CtD"}
@@ -0,0 +1,49 @@
1
+ import { resolveCredentials } from '../lib/client.js';
2
+ import { printError, printJson } from '../lib/output.js';
3
+ /**
4
+ * `parall wechat …` — the per-vendor platform verb for the personal-WeChat
5
+ * protocol channel (wechatapi.net, tier B in the multi-channel
6
+ * architecture). The agent sends as the connected WeChat account itself;
7
+ * the vendor token + device id never reach the runtime — api-server
8
+ * performs the vendor call in-process.
9
+ * INTERNAL research preview: the capability is flag-gated to the internal
10
+ * org. Design: docs/engineering-design/wechat-channel-design.md §3.
11
+ */
12
+ export function registerWechatCommands(program) {
13
+ const wechat = program
14
+ .command('wechat')
15
+ .description('WeChat channel platform verbs (agent-only; sends as the connected account)');
16
+ wechat
17
+ .command('send')
18
+ .description('Send a message to a WeChat conversation this connection has seen inbound')
19
+ .requiredOption('--to <wxid>', 'Vendor-native conversation id from the inbound event (a friend wxid, or a room id ending in @chatroom)')
20
+ .requiredOption('--text <text>', 'Message text (plain text)')
21
+ .option('--at <wxid>', 'In group replies: @-mention one member (wxid of the person you are answering)')
22
+ .action(async (opts) => {
23
+ try {
24
+ const { client, orgId } = resolveCredentials();
25
+ const sent = await client.sendChannelMessage(orgId, {
26
+ channel_type: 'wechat',
27
+ conversation_id: opts.to,
28
+ text: opts.text,
29
+ ...(opts.at ? { at: opts.at } : {}),
30
+ });
31
+ printJson(sent);
32
+ }
33
+ catch (err) {
34
+ printError(err);
35
+ }
36
+ });
37
+ wechat
38
+ .command('contacts')
39
+ .description('List the account address book: friend wxids, saved group room ids, followed official accounts (ids only — the vendor returns no names)')
40
+ .action(async () => {
41
+ try {
42
+ const { client, orgId } = resolveCredentials();
43
+ printJson(await client.listWechatContacts(orgId));
44
+ }
45
+ catch (err) {
46
+ printError(err);
47
+ }
48
+ });
49
+ }
package/dist/index.js CHANGED
@@ -21,6 +21,7 @@ import { registerFileCommands } from './commands/files.js';
21
21
  import { registerMachineCommands } from './commands/machines.js';
22
22
  import { registerClipCommands } from './commands/clip.js';
23
23
  import { registerSlackCommands } from './commands/slack.js';
24
+ import { registerWechatCommands } from './commands/wechat.js';
24
25
  const require = createRequire(import.meta.url);
25
26
  const pkg = require('../package.json');
26
27
  const program = new Command();
@@ -48,4 +49,5 @@ registerFileCommands(program);
48
49
  registerMachineCommands(program);
49
50
  registerClipCommands(program);
50
51
  registerSlackCommands(program);
52
+ registerWechatCommands(program);
51
53
  program.parse();
@@ -0,0 +1,14 @@
1
+ export interface PublishSource {
2
+ manifest: Record<string, unknown>;
3
+ files: Record<string, string>;
4
+ directoryName: string;
5
+ }
6
+ export declare function collectPublishSource(directory: string): PublishSource;
7
+ /**
8
+ * The manifest subset that gets stored in the registry: the commands map plus
9
+ * the metadata keys the exec/display sides read. Mirrors the Edge CLI's
10
+ * buildPublishedManifest — local-only fields (name/version/visibility ride as
11
+ * top-level request fields) stay out of the stored manifest.
12
+ */
13
+ export declare function buildPublishedManifest(manifest: Record<string, unknown>): Record<string, unknown>;
14
+ //# sourceMappingURL=publish-source.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"publish-source.d.ts","sourceRoot":"","sources":["../../src/lib/publish-source.ts"],"names":[],"mappings":"AA8BA,MAAM,WAAW,aAAa;IAC5B,QAAQ,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IAClC,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IAC9B,aAAa,EAAE,MAAM,CAAC;CACvB;AA4FD,wBAAgB,oBAAoB,CAAC,SAAS,EAAE,MAAM,GAAG,aAAa,CAmCrE;AAsBD;;;;;GAKG;AACH,wBAAgB,sBAAsB,CAAC,QAAQ,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CASjG"}
@@ -0,0 +1,158 @@
1
+ import { closeSync, constants, fstatSync, lstatSync, openSync, readFileSync, readdirSync, realpathSync, statSync, } from 'node:fs';
2
+ import { basename, isAbsolute, join, relative, sep } from 'node:path';
3
+ function fileIdentityMatches(a, b) {
4
+ return a.dev === b.dev && a.ino === b.ino;
5
+ }
6
+ function assertContained(root, candidate, name) {
7
+ const pathFromRoot = relative(root, candidate);
8
+ if (pathFromRoot === '..' || pathFromRoot.startsWith(`..${sep}`) || isAbsolute(pathFromRoot)) {
9
+ throw new Error(`Publish source escapes the clip directory: ${name}`);
10
+ }
11
+ }
12
+ function assertSourceRootIdentity(root, expected) {
13
+ let current;
14
+ try {
15
+ current = lstatSync(root);
16
+ }
17
+ catch {
18
+ throw new Error('Publish source directory changed while reading');
19
+ }
20
+ if (!current.isDirectory() || !fileIdentityMatches(expected, current)) {
21
+ throw new Error('Publish source directory changed while reading');
22
+ }
23
+ }
24
+ function readRegularFile(root, name) {
25
+ if (basename(name) !== name)
26
+ throw new Error(`Invalid publish source path: ${name}`);
27
+ const path = join(root, name);
28
+ const metadata = lstatSync(path);
29
+ if (metadata.isSymbolicLink())
30
+ throw new Error(`Publish source must not be a symlink: ${name}`);
31
+ if (!metadata.isFile())
32
+ throw new Error(`Publish source must be a regular file: ${name}`);
33
+ if (metadata.nlink !== 1)
34
+ throw new Error(`Publish source must not be a hard link: ${name}`);
35
+ const realPath = realpathSync(path);
36
+ assertContained(root, realPath, name);
37
+ const noFollow = typeof constants.O_NOFOLLOW === 'number' ? constants.O_NOFOLLOW : 0;
38
+ let descriptor;
39
+ try {
40
+ descriptor = openSync(path, constants.O_RDONLY | noFollow);
41
+ }
42
+ catch (error) {
43
+ const code = error?.code;
44
+ if (code === 'ELOOP' || code === 'EMLINK') {
45
+ throw new Error(`Publish source must not be a symlink: ${name}`);
46
+ }
47
+ throw error;
48
+ }
49
+ try {
50
+ const openedMetadata = fstatSync(descriptor);
51
+ if (!fileIdentityMatches(metadata, openedMetadata)) {
52
+ throw new Error(`Publish source changed while opening: ${name}`);
53
+ }
54
+ if (!openedMetadata.isFile()) {
55
+ throw new Error(`Publish source must be a regular file: ${name}`);
56
+ }
57
+ if (openedMetadata.nlink !== 1) {
58
+ throw new Error(`Publish source must not be a hard link: ${name}`);
59
+ }
60
+ return readFileSync(descriptor, 'utf8');
61
+ }
62
+ finally {
63
+ closeSync(descriptor);
64
+ }
65
+ }
66
+ function readManifest(root, entries, displayDirectory) {
67
+ const manifestNames = ['manifest.json', 'site.json'].filter((name) => entries.has(name));
68
+ if (manifestNames.length === 0) {
69
+ throw new Error(`No manifest.json or site.json found in ${displayDirectory}`);
70
+ }
71
+ let lastError;
72
+ for (const manifestName of manifestNames) {
73
+ // A readRegularFile refusal (symlink, swap, escape) propagates — safety
74
+ // refusals are not "try the next manifest" territory; only parse failures
75
+ // fall through to site.json.
76
+ const raw = readRegularFile(root, manifestName);
77
+ try {
78
+ const parsed = JSON.parse(raw);
79
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
80
+ throw new Error('manifest must be a JSON object');
81
+ }
82
+ return parsed;
83
+ }
84
+ catch (error) {
85
+ lastError = new Error(`Invalid ${manifestName}: ${error.message}`);
86
+ }
87
+ }
88
+ throw lastError;
89
+ }
90
+ export function collectPublishSource(directory) {
91
+ let root;
92
+ let rootMetadata;
93
+ try {
94
+ root = realpathSync(directory);
95
+ rootMetadata = statSync(root);
96
+ if (!rootMetadata.isDirectory())
97
+ throw new Error('not a directory');
98
+ }
99
+ catch {
100
+ throw new Error(`Clip directory is unavailable: ${directory}`);
101
+ }
102
+ const entries = new Map(readdirSync(root, { withFileTypes: true }).map((entry) => [entry.name, entry]));
103
+ assertSourceRootIdentity(root, rootMetadata);
104
+ const manifest = readManifest(root, entries, directory);
105
+ assertSourceRootIdentity(root, rootMetadata);
106
+ const scriptNames = [...entries.keys()].filter((name) => name.endsWith('.js')).sort();
107
+ if (scriptNames.length === 0) {
108
+ throw new Error(`No .js files found in ${directory}`);
109
+ }
110
+ const files = {};
111
+ for (const name of scriptNames) {
112
+ assertSourceRootIdentity(root, rootMetadata);
113
+ files[name] = readRegularFile(root, name);
114
+ assertSourceRootIdentity(root, rootMetadata);
115
+ }
116
+ // Fallback-name derivation: keep the name the user explicitly selected
117
+ // (including a symlinked directory's own name), but "." / ".." carry no
118
+ // name at all — resolve those through the real root, or `publish .` would
119
+ // fall back to a clip literally named "." (name is the org-wide upsert key).
120
+ const rawName = basename(directory);
121
+ const directoryName = rawName === '' || rawName === '.' || rawName === '..' ? basename(root) : rawName;
122
+ return { manifest, files, directoryName };
123
+ }
124
+ const PUBLISHED_METADATA_KEYS = ['type', 'domain', 'requires_login', 'server', 'auth'];
125
+ /**
126
+ * The explicit `type` values the platform recognizes. Absent/null means
127
+ * browser (the original clip.json shape predates the field), so only a
128
+ * declared-but-unrecognized value is refused — locally, before any request
129
+ * leaves the machine, mirroring the server's publish gate. "clip" gets a named
130
+ * hint because every Pinix v2 package manifest declares it, and a v2 manifest
131
+ * republished as-is used to be classified as an MCP clip downstream.
132
+ */
133
+ function assertPublishableManifestType(manifest) {
134
+ const type = manifest.type;
135
+ if (type == null || type === 'browser' || type === 'mcp')
136
+ return;
137
+ const message = type === 'clip'
138
+ ? 'manifest "type" "clip" is the legacy Pinix v2 package value — declare "browser" (or omit the field) for a browser clip, "mcp" for an MCP clip'
139
+ : `manifest "type" must be "browser" or "mcp" (or omitted for a browser clip); got ${JSON.stringify(type)}`;
140
+ throw new Error(`Invalid manifest: ${message}`);
141
+ }
142
+ /**
143
+ * The manifest subset that gets stored in the registry: the commands map plus
144
+ * the metadata keys the exec/display sides read. Mirrors the Edge CLI's
145
+ * buildPublishedManifest — local-only fields (name/version/visibility ride as
146
+ * top-level request fields) stay out of the stored manifest.
147
+ */
148
+ export function buildPublishedManifest(manifest) {
149
+ assertPublishableManifestType(manifest);
150
+ // `||`, matching the Edge CLI exactly: a falsy non-object `commands` (a
151
+ // malformed manifest) normalizes to {} rather than being published as-is.
152
+ const published = { commands: manifest.commands || {} };
153
+ for (const key of PUBLISHED_METADATA_KEYS) {
154
+ if (manifest[key] !== undefined)
155
+ published[key] = manifest[key];
156
+ }
157
+ return published;
158
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@parall/cli",
3
- "version": "1.52.0",
3
+ "version": "1.52.2",
4
4
  "description": "CLI client for Parall — universal agent & human access to Parall API",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -36,14 +36,14 @@
36
36
  "diff": "^8.0.3",
37
37
  "js-yaml": "^4.1.0",
38
38
  "zod": "^4.3.6",
39
- "@parall/sdk": "1.52.0",
40
- "@parall/agent-core": "1.52.0"
39
+ "@parall/agent-core": "1.52.2",
40
+ "@parall/sdk": "1.52.2"
41
41
  },
42
42
  "devDependencies": {
43
43
  "@types/js-yaml": "^4.0.9",
44
44
  "@types/node": "^22.0.0",
45
45
  "typescript": "^5.7.0",
46
- "@parall/agent-core": "1.52.0"
46
+ "@parall/agent-core": "1.52.2"
47
47
  },
48
48
  "scripts": {
49
49
  "build": "tsc",
@@ -1,45 +0,0 @@
1
- import type { InvokeClipRequest, InvokeClipResponse, ParallClient } from '@parall/sdk';
2
- /**
3
- * Hosted browser activation wait — CLI-side bounded retry for `clip invoke`.
4
- *
5
- * A clip bound to a `placement=hosted` BrowserProfile whose pod is cold returns
6
- * 503 BROWSER_PROFILE_ACTIVATING from the invoke pre-flight: the controller is
7
- * spawning + registering the pod, and the clip command has NOT been dispatched
8
- * yet, so retrying the same request cannot double-execute anything. This module
9
- * absorbs that one precise error so a single `parall clip invoke` rides through
10
- * cold start instead of forcing the caller (agent or human) into a blind
11
- * retry loop.
12
- *
13
- * Deliberately NOT in the SDK: `invokeClip()` keeps its throw-on-503 semantics
14
- * for every other consumer; only the CLI invoke behavior waits.
15
- */
16
- /** Total activation wait budget. Independent of the clip command --timeout:
17
- * each attempt keeps its own full command timeout, so worst-case wall clock is
18
- * roughly this budget plus one command timeout. */
19
- export declare const ACTIVATION_WAIT_BUDGET_MS = 60000;
20
- /** Backoff schedule between activation retries; the last entry repeats. */
21
- export declare const ACTIVATION_BACKOFF_MS: number[];
22
- /** Injectable time hooks so tests never really sleep. */
23
- export type ActivationWaitHooks = {
24
- sleep?: (ms: number) => Promise<void>;
25
- now?: () => number;
26
- };
27
- /**
28
- * The ONLY retryable error: a typed ApiError carrying exactly 503 +
29
- * BROWSER_PROFILE_ACTIVATING. Everything else — consent, permission, provider
30
- * offline/not-running, command errors, timeouts, other 5xx — means retrying is
31
- * either pointless or unsafe (the command may have been dispatched), so it
32
- * propagates unchanged on the first occurrence.
33
- */
34
- export declare function isActivationPending(err: unknown): boolean;
35
- /**
36
- * Invoke a clip, transparently absorbing the hosted-browser activation window.
37
- *
38
- * Retries the identical request while the server reports
39
- * BROWSER_PROFILE_ACTIVATING, backing off 500ms → 1s → 2s (capped) until the
40
- * 60s activation budget is spent. Once spent, the LAST typed ApiError is
41
- * rethrown as-is — status/code intact — so the printed error stays
42
- * machine-readable. Any other failure is rethrown immediately.
43
- */
44
- export declare function invokeClipAwaitingActivation(client: Pick<ParallClient, 'invokeClip'>, orgId: string, req: InvokeClipRequest, hooks?: ActivationWaitHooks): Promise<InvokeClipResponse>;
45
- //# sourceMappingURL=clip-invoke.d.ts.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"clip-invoke.d.ts","sourceRoot":"","sources":["../../src/lib/clip-invoke.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,iBAAiB,EAAE,kBAAkB,EAAE,YAAY,EAAE,MAAM,aAAa,CAAC;AAGvF;;;;;;;;;;;;;GAaG;AAEH;;oDAEoD;AACpD,eAAO,MAAM,yBAAyB,QAAS,CAAC;AAEhD,2EAA2E;AAC3E,eAAO,MAAM,qBAAqB,UAAsB,CAAC;AAEzD,yDAAyD;AACzD,MAAM,MAAM,mBAAmB,GAAG;IAChC,KAAK,CAAC,EAAE,CAAC,EAAE,EAAE,MAAM,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;IACtC,GAAG,CAAC,EAAE,MAAM,MAAM,CAAC;CACpB,CAAC;AAIF;;;;;;GAMG;AACH,wBAAgB,mBAAmB,CAAC,GAAG,EAAE,OAAO,GAAG,OAAO,CAEzD;AAED;;;;;;;;GAQG;AACH,wBAAsB,4BAA4B,CAChD,MAAM,EAAE,IAAI,CAAC,YAAY,EAAE,YAAY,CAAC,EACxC,KAAK,EAAE,MAAM,EACb,GAAG,EAAE,iBAAiB,EACtB,KAAK,CAAC,EAAE,mBAAmB,GAC1B,OAAO,CAAC,kBAAkB,CAAC,CAiB7B"}
@@ -1,61 +0,0 @@
1
- import { ApiError } from '@parall/sdk';
2
- /**
3
- * Hosted browser activation wait — CLI-side bounded retry for `clip invoke`.
4
- *
5
- * A clip bound to a `placement=hosted` BrowserProfile whose pod is cold returns
6
- * 503 BROWSER_PROFILE_ACTIVATING from the invoke pre-flight: the controller is
7
- * spawning + registering the pod, and the clip command has NOT been dispatched
8
- * yet, so retrying the same request cannot double-execute anything. This module
9
- * absorbs that one precise error so a single `parall clip invoke` rides through
10
- * cold start instead of forcing the caller (agent or human) into a blind
11
- * retry loop.
12
- *
13
- * Deliberately NOT in the SDK: `invokeClip()` keeps its throw-on-503 semantics
14
- * for every other consumer; only the CLI invoke behavior waits.
15
- */
16
- /** Total activation wait budget. Independent of the clip command --timeout:
17
- * each attempt keeps its own full command timeout, so worst-case wall clock is
18
- * roughly this budget plus one command timeout. */
19
- export const ACTIVATION_WAIT_BUDGET_MS = 60_000;
20
- /** Backoff schedule between activation retries; the last entry repeats. */
21
- export const ACTIVATION_BACKOFF_MS = [500, 1_000, 2_000];
22
- const realSleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
23
- /**
24
- * The ONLY retryable error: a typed ApiError carrying exactly 503 +
25
- * BROWSER_PROFILE_ACTIVATING. Everything else — consent, permission, provider
26
- * offline/not-running, command errors, timeouts, other 5xx — means retrying is
27
- * either pointless or unsafe (the command may have been dispatched), so it
28
- * propagates unchanged on the first occurrence.
29
- */
30
- export function isActivationPending(err) {
31
- return err instanceof ApiError && err.status === 503 && err.code === 'BROWSER_PROFILE_ACTIVATING';
32
- }
33
- /**
34
- * Invoke a clip, transparently absorbing the hosted-browser activation window.
35
- *
36
- * Retries the identical request while the server reports
37
- * BROWSER_PROFILE_ACTIVATING, backing off 500ms → 1s → 2s (capped) until the
38
- * 60s activation budget is spent. Once spent, the LAST typed ApiError is
39
- * rethrown as-is — status/code intact — so the printed error stays
40
- * machine-readable. Any other failure is rethrown immediately.
41
- */
42
- export async function invokeClipAwaitingActivation(client, orgId, req, hooks) {
43
- const sleep = hooks?.sleep ?? realSleep;
44
- const now = hooks?.now ?? Date.now;
45
- const deadline = now() + ACTIVATION_WAIT_BUDGET_MS;
46
- for (let attempt = 0;; attempt++) {
47
- try {
48
- return await client.invokeClip(orgId, req);
49
- }
50
- catch (err) {
51
- if (!isActivationPending(err))
52
- throw err;
53
- const remaining = deadline - now();
54
- if (remaining <= 0)
55
- throw err;
56
- const backoff = ACTIVATION_BACKOFF_MS[Math.min(attempt, ACTIVATION_BACKOFF_MS.length - 1)];
57
- // Clamp to the remaining budget so the final sleep can't overrun it.
58
- await sleep(Math.min(backoff, remaining));
59
- }
60
- }
61
- }