@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.
- package/dist/commands/chats.js +1 -1
- package/dist/commands/dm.d.ts.map +1 -1
- package/dist/commands/dm.js +5 -3
- package/dist/commands/messages.d.ts.map +1 -1
- package/dist/commands/messages.js +51 -8
- package/dist/commands/tasks.d.ts.map +1 -1
- package/dist/commands/tasks.js +47 -6
- package/dist/lib/client.d.ts +61 -0
- package/dist/lib/client.d.ts.map +1 -1
- package/dist/lib/client.js +173 -1
- package/dist/lib/text-input.d.ts +30 -0
- package/dist/lib/text-input.d.ts.map +1 -0
- package/dist/lib/text-input.js +61 -0
- package/package.json +4 -3
package/dist/commands/chats.js
CHANGED
|
@@ -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";
|
|
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;
|
|
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"}
|
package/dist/commands/dm.js
CHANGED
|
@@ -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>',
|
|
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
|
|
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(
|
|
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;
|
|
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 {
|
|
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>',
|
|
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
|
|
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
|
|
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(
|
|
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
|
-
|
|
100
|
-
|
|
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
|
-
|
|
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":"
|
|
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"}
|
package/dist/commands/tasks.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import {
|
|
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
|
-
|
|
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
|
-
.
|
|
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
|
-
|
|
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
|
-
.
|
|
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
|
-
|
|
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) {
|
package/dist/lib/client.d.ts
CHANGED
|
@@ -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
|
package/dist/lib/client.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"client.d.ts","sourceRoot":"","sources":["../../src/lib/client.ts"],"names":[],"mappings":"
|
|
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"}
|
package/dist/lib/client.js
CHANGED
|
@@ -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 {
|
|
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.
|
|
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/
|
|
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.
|
|
46
|
+
"@parall/agent-core": "1.38.0"
|
|
46
47
|
},
|
|
47
48
|
"scripts": {
|
|
48
49
|
"build": "tsc",
|