@parall/cli 1.36.1 → 1.38.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.
@@ -120,7 +120,7 @@ export function registerChatCommands(program) {
120
120
  .action(async (chatId, opts) => {
121
121
  try {
122
122
  if (opts.role !== undefined && opts.role !== 'member') {
123
- throw new Error('--role only supports "member"; update the role after adding the member');
123
+ throw new Error('--role only supports "member"; the CLI cannot change member roles');
124
124
  }
125
125
  const { client, orgId } = resolveCredentials();
126
126
  await client.addChatMember(orgId, stripPrllScheme(chatId), stripPrllScheme(opts.userId));
@@ -1 +1 @@
1
- {"version":3,"file":"dm.d.ts","sourceRoot":"","sources":["../../src/commands/dm.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;AAiCpC,wBAAgB,kBAAkB,CAAC,OAAO,EAAE,OAAO,QAwDlD"}
1
+ {"version":3,"file":"dm.d.ts","sourceRoot":"","sources":["../../src/commands/dm.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;AAuCpC,wBAAgB,kBAAkB,CAAC,OAAO,EAAE,OAAO,QA+DlD"}
@@ -1,5 +1,6 @@
1
1
  import { resolveCredentials, resolveRuntimeContext } from '../lib/client.js';
2
2
  import { printJson, printError, printRefHint, stripPrllScheme } from '../lib/output.js';
3
+ import { NO_BODY_ERROR, resolveMessageText, TEXT_FILE_OPTION_DESC, TEXT_OPTION_DESC, } from '../lib/text-input.js';
3
4
  import { uploadFile } from '../lib/upload.js';
4
5
  /**
5
6
  * Resolve a name-or-id argument to a user ID.
@@ -25,7 +26,8 @@ export function registerDMCommands(program) {
25
26
  .command('dm')
26
27
  .description('Send a direct message to a user by name or ID (auto-creates chat if needed)')
27
28
  .argument('<nameOrId>', 'Target user display name or ID (usr_...)')
28
- .option('--text <text>', 'Message text')
29
+ .option('--text <text>', TEXT_OPTION_DESC)
30
+ .option('--text-file <path>', TEXT_FILE_OPTION_DESC)
29
31
  .option('--file <path>', 'Upload and attach a local file')
30
32
  .option('--attachment <id>', 'Attach an existing attachment (att_xxx or prll://att_xxx)')
31
33
  .option('--no-reply', 'Hint that the recipient should not reply')
@@ -38,7 +40,7 @@ export function registerDMCommands(program) {
38
40
  printError(new Error('--file and --attachment are mutually exclusive'));
39
41
  return;
40
42
  }
41
- const text = opts.text || '';
43
+ const text = resolveMessageText(opts) || '';
42
44
  let attachmentIds;
43
45
  if (opts.file) {
44
46
  const result = await uploadFile(client, orgId, opts.file);
@@ -48,7 +50,7 @@ export function registerDMCommands(program) {
48
50
  attachmentIds = [stripPrllScheme(opts.attachment)];
49
51
  }
50
52
  if (!text && !attachmentIds) {
51
- printError(new Error('Provide --text, --file, or --attachment'));
53
+ printError(new Error(NO_BODY_ERROR));
52
54
  return;
53
55
  }
54
56
  const req = {
@@ -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;AAKzC,wBAAgB,uBAAuB,CAAC,OAAO,EAAE,OAAO,QAwJvD"}
1
+ {"version":3,"file":"messages.d.ts","sourceRoot":"","sources":["../../src/commands/messages.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;AAkBzC,wBAAgB,uBAAuB,CAAC,OAAO,EAAE,OAAO,QA8MvD"}
@@ -1,6 +1,8 @@
1
1
  import { ApiError } from '@parall/sdk';
2
- import { resolveCredentials, resolveRuntimeContext } from '../lib/client.js';
2
+ import { randomUUID } from 'node:crypto';
3
+ import { LaneContextError, markLaneReplyCommitted, resolveCredentials, resolveLaneDispatchContext, resolveRuntimeContext, } from '../lib/client.js';
3
4
  import { printError, printJson, printRefHint, stripPrllScheme } from '../lib/output.js';
5
+ import { NO_BODY_ERROR, resolveMessageText, TEXT_FILE_OPTION_DESC, TEXT_OPTION_DESC, } from '../lib/text-input.js';
4
6
  import { uploadFile } from '../lib/upload.js';
5
7
  export function registerMessageCommands(program) {
6
8
  const messages = program.command('messages').description('Manage messages');
@@ -53,7 +55,8 @@ export function registerMessageCommands(program) {
53
55
  .command('send')
54
56
  .description('Send a message to a chat (text, file, or both)')
55
57
  .argument('[chatId]', 'Chat ID (defaults to PRLL_CHAT_ID if set)')
56
- .option('--text <text>', 'Message text')
58
+ .option('--text <text>', TEXT_OPTION_DESC)
59
+ .option('--text-file <path>', TEXT_FILE_OPTION_DESC)
57
60
  .option('--file <path>', 'Upload and attach a local file')
58
61
  .option('--attachment <id>', 'Attach an existing attachment (att_xxx or prll://att_xxx)')
59
62
  .option('--thread-root-id <id>', 'Reply to a thread')
@@ -70,14 +73,14 @@ export function registerMessageCommands(program) {
70
73
  // `messages send` targets a chat. A user id here is an addressing
71
74
  // mistake — point at `dm` instead of letting the server reject it.
72
75
  if (chatId.startsWith('usr_')) {
73
- printError(new ApiError(400, `prll://${chatId} is a user, not a chat. To message a user use: parall dm prll://${chatId} --text "..."`, 'INVALID_TARGET'));
76
+ printError(new ApiError(400, `prll://${chatId} is a user, not a chat. To message a user use: parall dm prll://${chatId} --text-file -`, 'INVALID_TARGET'));
74
77
  return;
75
78
  }
76
79
  if (opts.file && opts.attachment) {
77
80
  printError(new Error('--file and --attachment are mutually exclusive'));
78
81
  return;
79
82
  }
80
- const text = opts.text || '';
83
+ const text = resolveMessageText(opts) || '';
81
84
  let attachmentIds;
82
85
  if (opts.file) {
83
86
  const result = await uploadFile(client, orgId, opts.file);
@@ -87,7 +90,7 @@ export function registerMessageCommands(program) {
87
90
  attachmentIds = [stripPrllScheme(opts.attachment)];
88
91
  }
89
92
  if (!text && !attachmentIds) {
90
- printError(new Error('Provide --text, --file, or --attachment'));
93
+ printError(new Error(NO_BODY_ERROR));
91
94
  return;
92
95
  }
93
96
  const req = {
@@ -96,19 +99,59 @@ export function registerMessageCommands(program) {
96
99
  };
97
100
  if (attachmentIds)
98
101
  req.attachment_ids = attachmentIds;
99
- if (opts.threadRootId !== undefined)
100
- req.thread_root_id = stripPrllScheme(opts.threadRootId);
102
+ const threadRootId = opts.threadRootId !== undefined ? stripPrllScheme(opts.threadRootId) : undefined;
103
+ if (threadRootId !== undefined)
104
+ req.thread_root_id = threadRootId;
101
105
  if (opts.reply === false)
102
106
  req.hints = { no_reply: true };
103
107
  if (ctx.stepId)
104
108
  req.agent_step_id = ctx.stepId;
105
109
  if (ctx.sessionId)
106
110
  req.agent_session_id = ctx.sessionId;
107
- const result = await client.sendMessage(orgId, chatId, req);
111
+ // Dispatch lane binding (PRLL_CONTEXT_DIR contract): a send whose
112
+ // exact (chat, thread) target has an active lane context rides the
113
+ // server's dispatch ledger — the lane token authorizes the write,
114
+ // and the FIRST conversational reply of the lane carries the
115
+ // reply:<dispatch_event_id> effect key so it resolves the dispatched
116
+ // work in the same transaction. Later sends in the same turn (and
117
+ // cross-chat sends, which find no lane context) use a random
118
+ // per-invocation idempotency key. All of this stays out of the
119
+ // model's view.
120
+ const laneCtx = resolveLaneDispatchContext(chatId, threadRootId);
121
+ let usedReplyKey = false;
122
+ if (laneCtx) {
123
+ req.dispatch_lane = laneCtx.lane;
124
+ if (laneCtx.dispatchEventId && !laneCtx.replyCommitted) {
125
+ req.idempotency_key = `reply:${laneCtx.dispatchEventId}`;
126
+ usedReplyKey = true;
127
+ }
128
+ else {
129
+ req.idempotency_key = `cli:${randomUUID()}`;
130
+ }
131
+ if (!req.agent_step_id && laneCtx.stepId)
132
+ req.agent_step_id = laneCtx.stepId;
133
+ if (!req.agent_session_id && laneCtx.sessionId)
134
+ req.agent_session_id = laneCtx.sessionId;
135
+ }
136
+ const { message: result, deduplicated } = await client.sendMessageDetailed(orgId, chatId, req);
137
+ if (laneCtx && usedReplyKey) {
138
+ markLaneReplyCommitted(laneCtx);
139
+ }
108
140
  printJson(result);
141
+ if (deduplicated && usedReplyKey) {
142
+ console.error('Note: this reply was already sent by a previous run (deduplicated) — do not resend it.');
143
+ }
109
144
  printRefHint(result.id, 'Sent');
110
145
  }
111
146
  catch (err) {
147
+ if (err instanceof ApiError && err.code === 'STALE_LANE') {
148
+ printError(new ApiError(err.status, 'Your processing slot was taken over by a newer run — stop working on this conversation.', 'STALE_LANE'));
149
+ return;
150
+ }
151
+ if (err instanceof LaneContextError) {
152
+ printError(err);
153
+ return;
154
+ }
112
155
  printError(err);
113
156
  }
114
157
  });
@@ -1 +1 @@
1
- {"version":3,"file":"tasks.d.ts","sourceRoot":"","sources":["../../src/commands/tasks.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;AAQpC,wBAAgB,oBAAoB,CAAC,OAAO,EAAE,OAAO,QAqTpD"}
1
+ {"version":3,"file":"tasks.d.ts","sourceRoot":"","sources":["../../src/commands/tasks.ts"],"names":[],"mappings":"AAEA,OAAO,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;AAQpC,wBAAgB,oBAAoB,CAAC,OAAO,EAAE,OAAO,QAsWpD"}
@@ -1,4 +1,5 @@
1
- import { resolveCredentials } from '../lib/client.js';
1
+ import { ApiError } from '@parall/sdk';
2
+ import { resolveCredentials, resolveTypedDispatchBinding } from '../lib/client.js';
2
3
  import { printJson, printError, printRefHint, stripPrllScheme } from '../lib/output.js';
3
4
  function stripUndefined(obj) {
4
5
  return Object.fromEntries(Object.entries(obj).filter(([, v]) => v !== undefined));
@@ -113,10 +114,30 @@ export function registerTaskCommands(program) {
113
114
  if (Object.keys(data).length === 0) {
114
115
  printError(new Error('No fields to update. Provide at least one option.'));
115
116
  }
116
- const result = await client.updateTask(orgId, taskId, data);
117
+ // Typed dispatch binding: when this turn is a typed dispatch about
118
+ // exactly this task, the update rides the claimed typed lane; the
119
+ // first update also carries the canonical task_update:<dsp> effect
120
+ // key so it resolves the dispatched work in the same transaction.
121
+ // Invisible to the model — same contract as message replies.
122
+ const binding = resolveTypedDispatchBinding(taskId);
123
+ const req = { ...data };
124
+ if (binding) {
125
+ req.dispatch_lane = binding.lane;
126
+ req.dispatch_event_id = binding.dispatchEventId;
127
+ if (binding.effectKey)
128
+ req.dispatch_effect_key = binding.effectKey;
129
+ }
130
+ const result = await client.updateTask(orgId, taskId, req);
131
+ if (binding?.effectKey) {
132
+ binding.markCommitted();
133
+ }
117
134
  printJson(result);
118
135
  }
119
136
  catch (err) {
137
+ if (err instanceof ApiError && err.code === 'STALE_LANE') {
138
+ printError(new ApiError(err.status, 'Your processing slot was taken over by a newer run — stop working on this task.', 'STALE_LANE'));
139
+ return;
140
+ }
120
141
  printError(err);
121
142
  }
122
143
  });
@@ -268,10 +289,20 @@ export function registerTaskCommands(program) {
268
289
  .command('watch')
269
290
  .description('Watch a task (subscribe to comment notifications)')
270
291
  .argument('<taskId>', 'Task ID')
271
- .action(async (taskId) => {
292
+ .option('--user-id <userId>', 'Subscribe another user instead of yourself (requires you to be the task creator, assignee, project lead, or an org owner/admin)')
293
+ .action(async (taskId, opts) => {
272
294
  try {
273
295
  const { client, orgId } = resolveCredentials();
274
- await client.watchTask(orgId, stripPrllScheme(taskId));
296
+ const normalizedTaskId = stripPrllScheme(taskId);
297
+ const targetUserId = opts.userId ? stripPrllScheme(opts.userId) : undefined;
298
+ // A self-targeted --user-id is equivalent to plain self-watch; route it
299
+ // back to /watch so the behaviour matches `parall tasks watch` exactly.
300
+ if (targetUserId && targetUserId !== (await client.getMe()).id) {
301
+ await client.subscribeTaskMember(orgId, normalizedTaskId, targetUserId);
302
+ }
303
+ else {
304
+ await client.watchTask(orgId, normalizedTaskId);
305
+ }
275
306
  printJson({ ok: true });
276
307
  }
277
308
  catch (err) {
@@ -282,10 +313,20 @@ export function registerTaskCommands(program) {
282
313
  .command('unwatch')
283
314
  .description('Unwatch a task (stop receiving comment notifications)')
284
315
  .argument('<taskId>', 'Task ID')
285
- .action(async (taskId) => {
316
+ .option('--user-id <userId>', 'Unsubscribe another user instead of yourself (requires you to be the task creator, assignee, project lead, or an org owner/admin)')
317
+ .action(async (taskId, opts) => {
286
318
  try {
287
319
  const { client, orgId } = resolveCredentials();
288
- await client.unwatchTask(orgId, stripPrllScheme(taskId));
320
+ const normalizedTaskId = stripPrllScheme(taskId);
321
+ const targetUserId = opts.userId ? stripPrllScheme(opts.userId) : undefined;
322
+ // A self-targeted --user-id is equivalent to plain self-unwatch; route it
323
+ // back to /watch so the behaviour matches `parall tasks unwatch` exactly.
324
+ if (targetUserId && targetUserId !== (await client.getMe()).id) {
325
+ await client.unsubscribeTaskMember(orgId, normalizedTaskId, targetUserId);
326
+ }
327
+ else {
328
+ await client.unwatchTask(orgId, normalizedTaskId);
329
+ }
289
330
  printJson({ ok: true });
290
331
  }
291
332
  catch (err) {
@@ -11,6 +11,12 @@ export type RuntimeContext = {
11
11
  triggerMessageId?: string;
12
12
  /** When true, CLI should not send automatic reply messages — the bridge handles text projection. */
13
13
  noReply: boolean;
14
+ /** Dispatch ledger binding of the current turn (additive; bridge-written). */
15
+ dispatchEventId?: string;
16
+ lane?: string;
17
+ laneTargetUri?: string;
18
+ /** Typed binding hint: the task this dispatch is about (task dispatch turns only). */
19
+ taskId?: string;
14
20
  };
15
21
  /**
16
22
  * Read per-dispatch context from ENV + sideband files.
@@ -21,5 +27,60 @@ export type RuntimeContext = {
21
27
  * 3. PRLL_STEP_ID_FILE plain text (legacy step-id-only backward compat)
22
28
  */
23
29
  export declare function resolveRuntimeContext(): RuntimeContext;
30
+ /** Typed dispatch binding for a task write, derived from the turn context. */
31
+ export type TypedDispatchBinding = {
32
+ lane: string;
33
+ dispatchEventId: string;
34
+ /** Set for the first (resolving) update of this dispatch. */
35
+ effectKey?: string;
36
+ markCommitted: () => void;
37
+ };
38
+ /**
39
+ * Resolve the typed-lane binding for `parall task update <taskId>`: the
40
+ * current turn must be a typed dispatch (lane target `dsp:<id>`) about this
41
+ * exact task. The first update carries the canonical
42
+ * `task_update:<dispatch_event_id>` effect key (resolves the WorkItem in the
43
+ * same transaction); later updates go keyless (lane-checked only). The
44
+ * committed flag lives in a CLI-owned sidecar keyed by the typed lane; when
45
+ * PRLL_CONTEXT_DIR is absent the sidecar can't exist, so no effect key is
46
+ * ever attached (a blind key would make a *different* second update replay
47
+ * the first one's result instead of applying).
48
+ */
49
+ export declare function resolveTypedDispatchBinding(taskId: string): TypedDispatchBinding | null;
50
+ /** Per-lane dispatch context (PRLL_CONTEXT_DIR contract), keyed by send target. */
51
+ export type LaneDispatchContext = {
52
+ lane: string;
53
+ dispatchEventId?: string;
54
+ targetUri: string;
55
+ threadRootId?: string;
56
+ sessionId?: string;
57
+ stepId?: string;
58
+ /** True when the reply slot for this lane was already consumed (sidecar). */
59
+ replyCommitted: boolean;
60
+ replyStatePath: string;
61
+ };
62
+ /**
63
+ * Error thrown when a lane context file exists for the send target but lacks
64
+ * the dispatch/lane fields — the strong-idempotency path must fail closed
65
+ * rather than silently degrade to a best-effort write.
66
+ */
67
+ export declare class LaneContextError extends Error {
68
+ }
69
+ /**
70
+ * Look up the dispatch lane context for a send target under the
71
+ * PRLL_CONTEXT_DIR contract. Returns null when the contract is not in effect
72
+ * (env unset) or no lane file exists for this exact (target, thread) — i.e.
73
+ * a cross-chat / non-dispatch send, which stays on the best-effort path.
74
+ *
75
+ * The lane key encodes the full lane identity: a thread send only matches a
76
+ * thread lane, a channel send only a channel lane.
77
+ */
78
+ export declare function resolveLaneDispatchContext(chatId: string, threadRootId?: string): LaneDispatchContext | null;
79
+ /**
80
+ * Record that this lane's first conversational reply has been committed. The
81
+ * sidecar is CLI-owned — never the bridge-owned context file, which the
82
+ * bridge rewrites throughout the turn and would clobber inline state.
83
+ */
84
+ export declare function markLaneReplyCommitted(laneCtx: LaneDispatchContext): void;
24
85
  export declare function resolveCredentials(): ResolvedCredentials;
25
86
  //# sourceMappingURL=client.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"client.d.ts","sourceRoot":"","sources":["../../src/lib/client.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,YAAY,EAAE,MAAM,aAAa,CAAC;AAE3C,MAAM,MAAM,mBAAmB,GAAG;IAChC,MAAM,EAAE,YAAY,CAAC;IACrB,KAAK,EAAE,MAAM,CAAC;CACf,CAAC;AAEF,6DAA6D;AAC7D,MAAM,MAAM,cAAc,GAAG;IAC3B,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAC1B,oGAAoG;IACpG,OAAO,EAAE,OAAO,CAAC;CAClB,CAAC;AAEF;;;;;;;GAOG;AACH,wBAAgB,qBAAqB,IAAI,cAAc,CAiCtD;AAED,wBAAgB,kBAAkB,IAAI,mBAAmB,CA8BxD"}
1
+ {"version":3,"file":"client.d.ts","sourceRoot":"","sources":["../../src/lib/client.ts"],"names":[],"mappings":"AAEA,OAAO,EAAE,YAAY,EAAE,MAAM,aAAa,CAAC;AAE3C,MAAM,MAAM,mBAAmB,GAAG;IAChC,MAAM,EAAE,YAAY,CAAC;IACrB,KAAK,EAAE,MAAM,CAAC;CACf,CAAC;AAEF,6DAA6D;AAC7D,MAAM,MAAM,cAAc,GAAG;IAC3B,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAC1B,oGAAoG;IACpG,OAAO,EAAE,OAAO,CAAC;IACjB,8EAA8E;IAC9E,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,sFAAsF;IACtF,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB,CAAC;AAEF;;;;;;;GAOG;AACH,wBAAgB,qBAAqB,IAAI,cAAc,CAmDtD;AAED,8EAA8E;AAC9E,MAAM,MAAM,oBAAoB,GAAG;IACjC,IAAI,EAAE,MAAM,CAAC;IACb,eAAe,EAAE,MAAM,CAAC;IACxB,6DAA6D;IAC7D,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,aAAa,EAAE,MAAM,IAAI,CAAC;CAC3B,CAAC;AAEF;;;;;;;;;;GAUG;AACH,wBAAgB,2BAA2B,CAAC,MAAM,EAAE,MAAM,GAAG,oBAAoB,GAAG,IAAI,CAwCvF;AAED,mFAAmF;AACnF,MAAM,MAAM,mBAAmB,GAAG;IAChC,IAAI,EAAE,MAAM,CAAC;IACb,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB,SAAS,EAAE,MAAM,CAAC;IAClB,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,6EAA6E;IAC7E,cAAc,EAAE,OAAO,CAAC;IACxB,cAAc,EAAE,MAAM,CAAC;CACxB,CAAC;AAEF;;;;GAIG;AACH,qBAAa,gBAAiB,SAAQ,KAAK;CAAG;AAE9C;;;;;;;;GAQG;AACH,wBAAgB,0BAA0B,CACxC,MAAM,EAAE,MAAM,EACd,YAAY,CAAC,EAAE,MAAM,GACpB,mBAAmB,GAAG,IAAI,CA+E5B;AAED;;;;GAIG;AACH,wBAAgB,sBAAsB,CAAC,OAAO,EAAE,mBAAmB,GAAG,IAAI,CAYzE;AAED,wBAAgB,kBAAkB,IAAI,mBAAmB,CA8BxD"}
@@ -1,4 +1,5 @@
1
1
  import * as fs from 'node:fs';
2
+ import { laneContextFilePath, laneReplyStateFilePath } from '@parall/agent-core';
2
3
  import { ParallClient } from '@parall/sdk';
3
4
  /**
4
5
  * Read per-dispatch context from ENV + sideband files.
@@ -14,6 +15,10 @@ export function resolveRuntimeContext() {
14
15
  let chatId = process.env.PRLL_CHAT_ID?.trim() || undefined;
15
16
  let triggerMessageId = process.env.PRLL_TRIGGER_MESSAGE_ID?.trim() || undefined;
16
17
  let noReply = process.env.PRLL_NO_REPLY === '1';
18
+ let dispatchEventId;
19
+ let lane;
20
+ let laneTargetUri;
21
+ let taskId;
17
22
  if (process.env.PRLL_CONTEXT_FILE) {
18
23
  try {
19
24
  const raw = fs.readFileSync(process.env.PRLL_CONTEXT_FILE, 'utf-8').trim();
@@ -25,6 +30,10 @@ export function resolveRuntimeContext() {
25
30
  stepId ??= ctx.step_id || undefined;
26
31
  if (ctx.no_reply === true)
27
32
  noReply = true;
33
+ dispatchEventId = ctx.dispatch_event_id || undefined;
34
+ lane = ctx.lane || undefined;
35
+ laneTargetUri = ctx.target_uri || undefined;
36
+ taskId = ctx.task_id || undefined;
28
37
  }
29
38
  }
30
39
  catch {
@@ -41,7 +50,170 @@ export function resolveRuntimeContext() {
41
50
  // File not yet written or unreadable — no step context
42
51
  }
43
52
  }
44
- return { sessionId, stepId, chatId, triggerMessageId, noReply };
53
+ return {
54
+ sessionId,
55
+ stepId,
56
+ chatId,
57
+ triggerMessageId,
58
+ noReply,
59
+ dispatchEventId,
60
+ lane,
61
+ laneTargetUri,
62
+ taskId,
63
+ };
64
+ }
65
+ /**
66
+ * Resolve the typed-lane binding for `parall task update <taskId>`: the
67
+ * current turn must be a typed dispatch (lane target `dsp:<id>`) about this
68
+ * exact task. The first update carries the canonical
69
+ * `task_update:<dispatch_event_id>` effect key (resolves the WorkItem in the
70
+ * same transaction); later updates go keyless (lane-checked only). The
71
+ * committed flag lives in a CLI-owned sidecar keyed by the typed lane; when
72
+ * PRLL_CONTEXT_DIR is absent the sidecar can't exist, so no effect key is
73
+ * ever attached (a blind key would make a *different* second update replay
74
+ * the first one's result instead of applying).
75
+ */
76
+ export function resolveTypedDispatchBinding(taskId) {
77
+ const ctx = resolveRuntimeContext();
78
+ if (!ctx.lane || !ctx.dispatchEventId)
79
+ return null;
80
+ if (!ctx.laneTargetUri?.startsWith('dsp:'))
81
+ return null;
82
+ if (!ctx.taskId || ctx.taskId !== taskId)
83
+ return null;
84
+ const binding = {
85
+ lane: ctx.lane,
86
+ dispatchEventId: ctx.dispatchEventId,
87
+ markCommitted: () => { },
88
+ };
89
+ const contextDir = process.env.PRLL_CONTEXT_DIR?.trim();
90
+ if (!contextDir)
91
+ return binding;
92
+ const sidecarPath = laneReplyStateFilePath(contextDir, ctx.laneTargetUri);
93
+ let committed = false;
94
+ try {
95
+ const sidecar = JSON.parse(fs.readFileSync(sidecarPath, 'utf-8'));
96
+ committed =
97
+ sidecar.reply_committed === true && sidecar.dispatch_event_id === ctx.dispatchEventId;
98
+ }
99
+ catch {
100
+ // No sidecar yet — resolving update not committed.
101
+ }
102
+ if (!committed) {
103
+ const dispatchEventId = ctx.dispatchEventId;
104
+ binding.effectKey = `task_update:${dispatchEventId}`;
105
+ binding.markCommitted = () => {
106
+ try {
107
+ fs.writeFileSync(sidecarPath, JSON.stringify({ dispatch_event_id: dispatchEventId, reply_committed: true }), 'utf-8');
108
+ }
109
+ catch {
110
+ // Best-effort — a lost sidecar means the next update retries the key
111
+ // and the server's effect ledger replies idempotently.
112
+ }
113
+ };
114
+ }
115
+ return binding;
116
+ }
117
+ /**
118
+ * Error thrown when a lane context file exists for the send target but lacks
119
+ * the dispatch/lane fields — the strong-idempotency path must fail closed
120
+ * rather than silently degrade to a best-effort write.
121
+ */
122
+ export class LaneContextError extends Error {
123
+ }
124
+ /**
125
+ * Look up the dispatch lane context for a send target under the
126
+ * PRLL_CONTEXT_DIR contract. Returns null when the contract is not in effect
127
+ * (env unset) or no lane file exists for this exact (target, thread) — i.e.
128
+ * a cross-chat / non-dispatch send, which stays on the best-effort path.
129
+ *
130
+ * The lane key encodes the full lane identity: a thread send only matches a
131
+ * thread lane, a channel send only a channel lane.
132
+ */
133
+ export function resolveLaneDispatchContext(chatId, threadRootId) {
134
+ const contextDir = process.env.PRLL_CONTEXT_DIR?.trim();
135
+ if (!contextDir)
136
+ return null;
137
+ const targetUri = `prll://${chatId}`;
138
+ const filePath = laneContextFilePath(contextDir, targetUri, threadRootId);
139
+ let raw;
140
+ try {
141
+ raw = fs.readFileSync(filePath, 'utf-8').trim();
142
+ }
143
+ catch (err) {
144
+ // Only a genuinely-absent file means "not a dispatch-bound send".
145
+ // Permission errors etc. must fail closed, not silently degrade the
146
+ // write out of the ledger.
147
+ const code = err.code;
148
+ if (code === 'ENOENT' || code === 'ENOTDIR')
149
+ return null;
150
+ throw new LaneContextError(`Dispatch context for ${targetUri} is unreadable (${filePath}) — retry, or escalate if this persists`);
151
+ }
152
+ if (!raw) {
153
+ throw new LaneContextError(`Dispatch context for ${targetUri} is empty (${filePath}) — retry, or escalate if this persists`);
154
+ }
155
+ let ctx;
156
+ try {
157
+ ctx = JSON.parse(raw);
158
+ }
159
+ catch {
160
+ throw new LaneContextError(`Dispatch context for ${targetUri} is unreadable (${filePath}) — retry, or escalate if this persists`);
161
+ }
162
+ const lane = typeof ctx.lane === 'string' && ctx.lane ? ctx.lane : undefined;
163
+ if (!lane) {
164
+ // File exists but the lane fields are missing: fail closed instead of
165
+ // silently writing a side effect outside the ledger.
166
+ throw new LaneContextError(`Dispatch context for ${targetUri} is missing its lane binding — retry, or escalate if this persists`);
167
+ }
168
+ const dispatchEventId = typeof ctx.dispatch_event_id === 'string' && ctx.dispatch_event_id
169
+ ? ctx.dispatch_event_id
170
+ : undefined;
171
+ if (!dispatchEventId) {
172
+ // The bridge folds every group member before dispatching (fail-closed
173
+ // ensureLane), so a lane context without its trigger WorkItem id is
174
+ // corruption — never a legitimate degraded state.
175
+ throw new LaneContextError(`Dispatch context for ${targetUri} is missing its dispatch_event_id — retry, or escalate if this persists`);
176
+ }
177
+ const replyStatePath = laneReplyStateFilePath(contextDir, targetUri, threadRootId);
178
+ let replyCommitted = false;
179
+ try {
180
+ const sidecar = JSON.parse(fs.readFileSync(replyStatePath, 'utf-8'));
181
+ // The sidecar only counts for the same trigger — a new turn (new
182
+ // dispatch_event_id) resets the reply slot.
183
+ replyCommitted =
184
+ sidecar.reply_committed === true &&
185
+ dispatchEventId != null &&
186
+ sidecar.dispatch_event_id === dispatchEventId;
187
+ }
188
+ catch {
189
+ // No sidecar yet — reply slot unused.
190
+ }
191
+ return {
192
+ lane,
193
+ dispatchEventId,
194
+ targetUri,
195
+ threadRootId,
196
+ sessionId: typeof ctx.session_id === 'string' && ctx.session_id ? ctx.session_id : undefined,
197
+ stepId: typeof ctx.step_id === 'string' && ctx.step_id ? ctx.step_id : undefined,
198
+ replyCommitted,
199
+ replyStatePath,
200
+ };
201
+ }
202
+ /**
203
+ * Record that this lane's first conversational reply has been committed. The
204
+ * sidecar is CLI-owned — never the bridge-owned context file, which the
205
+ * bridge rewrites throughout the turn and would clobber inline state.
206
+ */
207
+ export function markLaneReplyCommitted(laneCtx) {
208
+ if (!laneCtx.dispatchEventId)
209
+ return;
210
+ try {
211
+ fs.writeFileSync(laneCtx.replyStatePath, JSON.stringify({ dispatch_event_id: laneCtx.dispatchEventId, reply_committed: true }), 'utf-8');
212
+ }
213
+ catch {
214
+ // Best-effort — a lost sidecar means the next send retries the reply key
215
+ // and the server's effect ledger dedupes it.
216
+ }
45
217
  }
46
218
  export function resolveCredentials() {
47
219
  const url = process.env.PRLL_API_URL;
@@ -0,0 +1,30 @@
1
+ export declare const TEXT_OPTION_DESC = "Message text (shell-safe only for short literals with no $, backtick, or quote)";
2
+ export declare const TEXT_FILE_OPTION_DESC = "Read message text from a file ('-' = stdin) \u2014 shell-safe channel for content with $, backticks, or quotes";
3
+ export declare const NO_BODY_ERROR = "Provide --text, --text-file, --file, or --attachment";
4
+ /**
5
+ * Resolve message body text from the mutually-exclusive `--text` / `--text-file`
6
+ * options shared by `messages send` and `dm`.
7
+ *
8
+ * Agents build these commands with an LLM and run them through a shell, so any
9
+ * body passed as a double-quoted `--text "..."` argument is mangled by shell
10
+ * expansion *before* the CLI ever sees it: `$1,000` becomes `,000`, `$USER`
11
+ * expands, and `` `cmd` `` / `$(cmd)` execute. Single quotes are no better —
12
+ * they break on the apostrophes that fill natural-language replies. `--text-file`
13
+ * sidesteps the shell entirely: the body comes from a file the agent wrote (raw
14
+ * bytes, no shell) or from stdin via a quoted heredoc (`<<'EOF'`, which disables
15
+ * all expansion). `-` means stdin.
16
+ *
17
+ * A single trailing newline — the structural newline a heredoc or editor appends
18
+ * — is stripped so a one-line reply doesn't arrive with a dangling blank line;
19
+ * this mirrors how `$(...)` command substitution trims trailing newlines, and
20
+ * leading/interior whitespace is preserved untouched.
21
+ *
22
+ * Returns `undefined` when neither option is set (preserving the old
23
+ * `opts.text`-or-nothing contract). Throws on conflicting options or an
24
+ * unreadable file; callers already funnel that into `printError`.
25
+ */
26
+ export declare function resolveMessageText(opts: {
27
+ text?: string;
28
+ textFile?: string;
29
+ }): string | undefined;
30
+ //# sourceMappingURL=text-input.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"text-input.d.ts","sourceRoot":"","sources":["../../src/lib/text-input.ts"],"names":[],"mappings":"AAMA,eAAO,MAAM,gBAAgB,oFACsD,CAAC;AACpF,eAAO,MAAM,qBAAqB,mHAC2E,CAAC;AAC9G,eAAO,MAAM,aAAa,yDAAyD,CAAC;AAEpF;;;;;;;;;;;;;;;;;;;;;GAqBG;AACH,wBAAgB,kBAAkB,CAAC,IAAI,EAAE;IAAE,IAAI,CAAC,EAAE,MAAM,CAAC;IAAC,QAAQ,CAAC,EAAE,MAAM,CAAA;CAAE,GAAG,MAAM,GAAG,SAAS,CA8BjG"}
@@ -0,0 +1,61 @@
1
+ import * as fs from 'node:fs';
2
+ import * as path from 'node:path';
3
+ // Shared option help + validation copy for the message-body flags, so
4
+ // `messages send` and `dm` stay in lockstep. commander's --help prints these
5
+ // verbatim (no markdown), so keep them backtick-free.
6
+ export const TEXT_OPTION_DESC = 'Message text (shell-safe only for short literals with no $, backtick, or quote)';
7
+ export const TEXT_FILE_OPTION_DESC = "Read message text from a file ('-' = stdin) — shell-safe channel for content with $, backticks, or quotes";
8
+ export const NO_BODY_ERROR = 'Provide --text, --text-file, --file, or --attachment';
9
+ /**
10
+ * Resolve message body text from the mutually-exclusive `--text` / `--text-file`
11
+ * options shared by `messages send` and `dm`.
12
+ *
13
+ * Agents build these commands with an LLM and run them through a shell, so any
14
+ * body passed as a double-quoted `--text "..."` argument is mangled by shell
15
+ * expansion *before* the CLI ever sees it: `$1,000` becomes `,000`, `$USER`
16
+ * expands, and `` `cmd` `` / `$(cmd)` execute. Single quotes are no better —
17
+ * they break on the apostrophes that fill natural-language replies. `--text-file`
18
+ * sidesteps the shell entirely: the body comes from a file the agent wrote (raw
19
+ * bytes, no shell) or from stdin via a quoted heredoc (`<<'EOF'`, which disables
20
+ * all expansion). `-` means stdin.
21
+ *
22
+ * A single trailing newline — the structural newline a heredoc or editor appends
23
+ * — is stripped so a one-line reply doesn't arrive with a dangling blank line;
24
+ * this mirrors how `$(...)` command substitution trims trailing newlines, and
25
+ * leading/interior whitespace is preserved untouched.
26
+ *
27
+ * Returns `undefined` when neither option is set (preserving the old
28
+ * `opts.text`-or-nothing contract). Throws on conflicting options or an
29
+ * unreadable file; callers already funnel that into `printError`.
30
+ */
31
+ export function resolveMessageText(opts) {
32
+ if (opts.text !== undefined && opts.textFile !== undefined) {
33
+ throw new Error('--text and --text-file are mutually exclusive');
34
+ }
35
+ if (opts.textFile === undefined)
36
+ return opts.text;
37
+ let raw;
38
+ if (opts.textFile === '-') {
39
+ // fd 0 = stdin; readFileSync drains a heredoc / pipe synchronously. Guard
40
+ // the interactive case — reading a TTY would block until EOF (which an
41
+ // agent never sends), hanging the dispatch until its deadline.
42
+ if (process.stdin.isTTY) {
43
+ throw new Error("--text-file - reads the body from stdin; pipe it in (e.g. a quoted heredoc `<<'EOF'`), don't run it interactively");
44
+ }
45
+ raw = fs.readFileSync(0, 'utf-8');
46
+ }
47
+ else {
48
+ const resolved = path.resolve(opts.textFile);
49
+ try {
50
+ raw = fs.readFileSync(resolved, 'utf-8');
51
+ }
52
+ catch (err) {
53
+ // Surface the underlying reason (missing / no permission / is-a-directory)
54
+ // so the agent can self-correct — printError renders only `.message` — and
55
+ // keep the original error as `cause` for anything that inspects it.
56
+ const reason = err instanceof Error ? err.message : String(err);
57
+ throw new Error(`Cannot read --text-file ${opts.textFile}: ${reason}`, { cause: err });
58
+ }
59
+ }
60
+ return raw.replace(/\r?\n$/, '');
61
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@parall/cli",
3
- "version": "1.36.1",
3
+ "version": "1.38.0",
4
4
  "description": "CLI client for Parall — universal agent & human access to Parall API",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -36,13 +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.36.1"
39
+ "@parall/agent-core": "1.38.0",
40
+ "@parall/sdk": "1.38.0"
40
41
  },
41
42
  "devDependencies": {
42
43
  "@types/js-yaml": "^4.0.9",
43
44
  "@types/node": "^22.0.0",
44
45
  "typescript": "^5.7.0",
45
- "@parall/agent-core": "1.36.1"
46
+ "@parall/agent-core": "1.38.0"
46
47
  },
47
48
  "scripts": {
48
49
  "build": "tsc",