@parall/cli 1.37.0 → 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":"messages.d.ts","sourceRoot":"","sources":["../../src/commands/messages.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;AAWzC,wBAAgB,uBAAuB,CAAC,OAAO,EAAE,OAAO,QA0JvD"}
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,5 +1,6 @@
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';
4
5
  import { NO_BODY_ERROR, resolveMessageText, TEXT_FILE_OPTION_DESC, TEXT_OPTION_DESC, } from '../lib/text-input.js';
5
6
  import { uploadFile } from '../lib/upload.js';
@@ -98,19 +99,59 @@ export function registerMessageCommands(program) {
98
99
  };
99
100
  if (attachmentIds)
100
101
  req.attachment_ids = attachmentIds;
101
- if (opts.threadRootId !== undefined)
102
- 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;
103
105
  if (opts.reply === false)
104
106
  req.hints = { no_reply: true };
105
107
  if (ctx.stepId)
106
108
  req.agent_step_id = ctx.stepId;
107
109
  if (ctx.sessionId)
108
110
  req.agent_session_id = ctx.sessionId;
109
- 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
+ }
110
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
+ }
111
144
  printRefHint(result.id, 'Sent');
112
145
  }
113
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
+ }
114
155
  printError(err);
115
156
  }
116
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,QA6UpD"}
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
  });
@@ -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;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@parall/cli",
3
- "version": "1.37.0",
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.37.0"
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.37.0"
46
+ "@parall/agent-core": "1.38.0"
46
47
  },
47
48
  "scripts": {
48
49
  "build": "tsc",