@parall/agent-core 1.36.1 → 1.37.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.
@@ -6,6 +6,11 @@ import type { GatewayLogger } from './dispatch-adapter.js';
6
6
  export interface PlatformDefaults {
7
7
  model: string | null;
8
8
  thinkingEffort: string | null;
9
+ // Optional so adding it didn't break existing constructors of this exported
10
+ // type. Absent/undefined = old server didn't send model_is_pin → fall back to
11
+ // model_management; true = operator PIN (override, beats env); false = catalog
12
+ // FLOOR (loses to env).
13
+ modelIsPin?: boolean;
9
14
  }
10
15
 
11
16
  export interface PlatformConfigManager {
@@ -19,6 +24,12 @@ export interface PlatformManagementProfile {
19
24
  model_management?: string | null;
20
25
  }
21
26
 
27
+ /**
28
+ * @deprecated Bridges now derive pin-vs-floor via {@link deriveModelIsPin}
29
+ * (presence-gated dual-read of the server's `model_is_pin`). This is retained
30
+ * only as the legacy fallback inside `deriveModelIsPin` (for old servers that
31
+ * omit the field) and for any third-party consumers. Prefer `deriveModelIsPin`.
32
+ */
22
33
  export function isPlatformManagedProfile(
23
34
  profile: PlatformManagementProfile | null | undefined,
24
35
  ): boolean {
@@ -34,6 +45,10 @@ export function isPlatformManagedProfile(
34
45
  * are not. Pass the result as `resolveRuntimeModel`'s first argument. Keeping
35
46
  * this derivation in one place avoids drift across the bridges' (re)config
36
47
  * sites where the subtle null-hosted-vs-explicit-self distinction matters.
48
+ *
49
+ * @deprecated Used only as the legacy fallback inside {@link deriveModelIsPin}
50
+ * (old servers that omit `model_is_pin`). New code should read `model_is_pin`
51
+ * via `deriveModelIsPin` rather than calling this directly.
37
52
  */
38
53
  export function isPlatformModelOverride(
39
54
  profile: PlatformManagementProfile | null | undefined,
@@ -64,6 +79,19 @@ export function resolveRuntimeModel(
64
79
  return operatorOverride ?? configModel ?? platformModel ?? undefined;
65
80
  }
66
81
 
82
+ /**
83
+ * Whether the platform-delivered model is an operator PIN (override) vs a catalog
84
+ * FLOOR. Presence-gated dual-read: a new server sends `model_is_pin` (use it); an
85
+ * old server omits it (`undefined`) so we fall back to the legacy
86
+ * `model_management`-derived signal. Pass the result to `resolveRuntimeModel`.
87
+ */
88
+ export function deriveModelIsPin(
89
+ defaults: PlatformDefaults,
90
+ profile: PlatformManagementProfile | null | undefined,
91
+ ): boolean {
92
+ return defaults.modelIsPin !== undefined ? defaults.modelIsPin : isPlatformModelOverride(profile);
93
+ }
94
+
67
95
  interface PlatformModelDef {
68
96
  id?: unknown;
69
97
  runtime_names?: unknown;
@@ -72,6 +100,9 @@ interface PlatformModelDef {
72
100
  interface CachedPlatformConfig {
73
101
  version: string;
74
102
  config: Record<string, unknown>;
103
+ // Top-level sibling of `config` (model_is_pin lives on the response, not inside
104
+ // config). Old cache files lack it → undefined → bridge falls back.
105
+ modelIsPin?: boolean;
75
106
  fetchedAt: string;
76
107
  }
77
108
 
@@ -95,6 +126,7 @@ function saveCache(stateDir: string, response: PlatformConfigResponse): void {
95
126
  const cached: CachedPlatformConfig = {
96
127
  version: response.version,
97
128
  config: response.config,
129
+ modelIsPin: response.model_is_pin,
98
130
  fetchedAt: new Date().toISOString(),
99
131
  };
100
132
  const filePath = cachePath(stateDir);
@@ -110,25 +142,49 @@ function runtimeModelName(
110
142
  config: Record<string, unknown>,
111
143
  ): string | null {
112
144
  const runtime = runtimeType?.trim();
113
- if (!runtime || runtime === 'openclaw') return canonicalModel;
145
+ if (!runtime || runtime === 'openclaw' || runtime === 'hermes') return canonicalModel;
114
146
 
115
147
  const models = (config.models ?? {}) as Record<string, unknown>;
116
148
  const providers = (models.providers ?? {}) as Record<string, unknown>;
117
149
  const parall = (providers.parall ?? {}) as Record<string, unknown>;
118
150
  const catalog = Array.isArray(parall.models) ? (parall.models as PlatformModelDef[]) : [];
119
151
  const match = catalog.find((model) => model.id === canonicalModel);
120
- const runtimeNames = (match?.runtime_names ?? {}) as Record<string, unknown>;
152
+ // Unknown to the catalog: let the bridge fall back to env/default.
153
+ if (!match) return null;
154
+ const runtimeNames = (match.runtime_names ?? {}) as Record<string, unknown>;
121
155
  const runtimeName = runtimeNames[runtime];
122
- return typeof runtimeName === 'string' && runtimeName ? runtimeName : null;
156
+ if (typeof runtimeName === 'string' && runtimeName) return runtimeName;
157
+ // Cross-family on the Parall proxy route: this catalog model carries no
158
+ // native name for this runtime's CLI. Fall back to the canonical
159
+ // provider/model id — the Parall proxy accepts it and routes via OpenRouter's
160
+ // universal skin, so the CLI's native wire format still reaches the upstream.
161
+ // Mirrors the server's RuntimeModelName; only reachable on the Parall route
162
+ // (own routes are family-locked at write time to same-family models).
163
+ return canonicalModel;
123
164
  }
124
165
 
125
- function extractDefaults(config: Record<string, unknown>, runtimeType?: string): PlatformDefaults {
166
+ // Exported for unit testing of the proxy-prefix normalization (see
167
+ // test/platform-config.test.mjs). Not part of the bridge-facing API surface.
168
+ // modelIsPin is NOT extracted here — it lives on the response/cache envelope
169
+ // (a sibling of `config`), so callers compose it in themselves.
170
+ export function extractDefaults(
171
+ config: Record<string, unknown>,
172
+ runtimeType: string | undefined,
173
+ ): Omit<PlatformDefaults, 'modelIsPin'> {
126
174
  const agents = (config.agents ?? {}) as Record<string, unknown>;
127
175
  const defaults = (agents.defaults ?? {}) as Record<string, unknown>;
128
176
 
129
177
  let model: string | null = null;
130
178
  if (typeof defaults.model === 'string' && defaults.model) {
131
- const canonicalModel = defaults.model.replace(/^parall\//, '');
179
+ // Strip the proxy prefix before catalog translation. OpenClaw writers may
180
+ // rewrite parall/anthropic/... → parall-anthropic/anthropic/... (prompt-cache
181
+ // transport); both are proxy forms and a shared LKG cache can hold either.
182
+ // Strip parall-anthropic/ first, then any remaining parall/ — sequential
183
+ // (not either/or) and both via the same anchored regex, so a doubly-prefixed
184
+ // value normalizes fully and the two branches can't diverge on edge cases.
185
+ const canonicalModel = defaults.model
186
+ .replace(/^parall-anthropic\//, '')
187
+ .replace(/^parall\//, '');
132
188
  model = runtimeModelName(canonicalModel, runtimeType, config);
133
189
  }
134
190
 
@@ -148,14 +204,31 @@ export function createPlatformConfigManager(opts: {
148
204
  }): PlatformConfigManager {
149
205
  const { client, stateDir, runtimeType, log } = opts;
150
206
  let cachedVersion: string | undefined;
151
- let currentDefaults: PlatformDefaults = { model: null, thinkingEffort: null };
207
+ let currentDefaults: PlatformDefaults = {
208
+ model: null,
209
+ thinkingEffort: null,
210
+ modelIsPin: undefined,
211
+ };
152
212
  let currentRawConfig: Record<string, unknown> | null = null;
153
213
 
154
214
  const cached = loadCache(stateDir);
155
215
  if (cached) {
156
- cachedVersion = cached.version;
216
+ // Pre-model_is_pin cache schema (no modelIsPin field): keep the LKG
217
+ // defaults, but DROP the ETag so the next fetch returns a full 200 and
218
+ // rewrites the cache in the new schema. With the ETag, a server whose
219
+ // config hasn't changed would 304 forever and strand
220
+ // modelIsPin=undefined (permanent legacy model_management fallback) on a
221
+ // fully-upgraded stack — the rollout order "server first, bridge later"
222
+ // makes an old-schema cache holding a CURRENT ETag the normal case.
223
+ // Against an old server (which never sends model_is_pin) this costs one
224
+ // full fetch per process start; acceptable, and disappears once the
225
+ // server is upgraded.
226
+ cachedVersion = cached.modelIsPin === undefined ? undefined : cached.version;
157
227
  currentRawConfig = cached.config;
158
- currentDefaults = extractDefaults(cached.config, runtimeType);
228
+ currentDefaults = {
229
+ ...extractDefaults(cached.config, runtimeType),
230
+ modelIsPin: cached.modelIsPin,
231
+ };
159
232
  }
160
233
 
161
234
  return {
@@ -189,7 +262,10 @@ export function createPlatformConfigManager(opts: {
189
262
  saveCache(stateDir, fresh);
190
263
  cachedVersion = fresh.version;
191
264
  currentRawConfig = fresh.config;
192
- currentDefaults = extractDefaults(fresh.config, runtimeType);
265
+ currentDefaults = {
266
+ ...extractDefaults(fresh.config, runtimeType),
267
+ modelIsPin: fresh.model_is_pin,
268
+ };
193
269
  return currentDefaults;
194
270
  },
195
271
 
@@ -199,6 +199,8 @@ Or upload first and reuse across chats:
199
199
  parall messages send prll://cht_aaa --attachment att_yyy --text "Report"
200
200
  parall messages send prll://cht_bbb --attachment att_yyy --text "FYI"
201
201
 
202
+ The \`--text\` captions above are safe short literals. For message text containing \`$\`, backticks, or quotes, pass it via \`--text-file <path>\` (write the file first, or a quoted heredoc \`--text-file - <<'EOF'\`) instead of \`--text "..."\` — inside double quotes the shell turns \`$1,000\` into \`,000\` and executes \`$(...)\`.
203
+
202
204
  ### When to reference
203
205
 
204
206
  - **Origin** — always link the message or task that triggered your work
@@ -98,16 +98,35 @@ original discussion and its approval/rejection IS the precedent.
98
98
 
99
99
  Each \`[Event: message.new]\` includes \`[Chat: ... (prll://cht_xxx)]\` — use that chat URI to reply.
100
100
 
101
+ > **How you pass the message body matters — your command runs through a shell.**
102
+ > Inside double quotes the shell expands \`$\`, backticks, and \`$(...)\` *before*
103
+ > the CLI sees them: \`--text "That costs $1,000"\` sends \`That costs ,000\`, and
104
+ > \`--text "$(cmd)"\` runs \`cmd\`. Single quotes instead break on apostrophes
105
+ > (\`I'm\`, \`don't\`). So do **not** wrap real message content in quotes — pass it
106
+ > through \`--text-file\` (a written file, or a quoted heredoc \`<<'EOF'\` that
107
+ > disables all expansion). Reserve \`--text "..."\` for short literals with no
108
+ > \`$\`, backtick, or apostrophe.
109
+
101
110
  \`\`\`bash
102
- # Reply to a chat (use the chat URI from the event)
103
- parall messages send prll://cht_xxx --text "Your reply"
111
+ # One-off reply quoted heredoc into stdin. The quoted delimiter <<'EOF'
112
+ # disables ALL shell expansion, so $, backticks and apostrophes pass verbatim.
113
+ parall messages send prll://cht_xxx --text-file - <<'PARALL_EOF'
114
+ Sure — that's $1,000, and $(whoami) stays literal. I'm on it.
115
+ PARALL_EOF
116
+
117
+ # Longer / multi-line reply → write it with your file tool (no shell touches
118
+ # the body), then point --text-file at the file.
119
+ parall messages send prll://cht_xxx --text-file /tmp/reply.md
120
+
121
+ # Short literal with no $, backtick, or apostrophe → --text is fine.
122
+ parall messages send prll://cht_xxx --text "On it"
104
123
 
105
- # Direct message by user URI or display name
106
- parall dm prll://usr_xxx --text "Hello"
124
+ # Direct message by user URI or display name (same --text-file / heredoc rules)
125
+ parall dm prll://usr_xxx --text-file /tmp/reply.md
107
126
  parall dm "Alice" --text "Hello"
108
127
 
109
128
  # Thread reply
110
- parall messages send prll://cht_xxx --text "Reply" --thread-root-id 01JWC...
129
+ parall messages send prll://cht_xxx --text-file /tmp/reply.md --thread-root-id 01JWC...
111
130
 
112
131
  # FYI message (no response expected — the recipient sees \`[Hint: no_reply]\`)
113
132
  parall messages send prll://cht_xxx --text "FYI: done" --no-reply
@@ -139,7 +158,9 @@ parall messages send prll://cht_xxx --attachment att_xxx --text "See attached"
139
158
  parall dm "Alice" --file /tmp/report.pdf --text "Report attached"
140
159
  \`\`\`
141
160
 
142
- \`--file\` and \`--attachment\` are mutually exclusive. \`--text\` can be combined with either.
161
+ \`--file\` and \`--attachment\` are mutually exclusive. A caption (\`--text\` for
162
+ short literals, or \`--text-file\` for anything with \`$\`, backticks, or quotes)
163
+ can be combined with either.
143
164
 
144
165
  ## Approvals
145
166
 
package/src/types.ts CHANGED
@@ -31,6 +31,7 @@ export type ParallEvent = {
31
31
  | 'wiki_comment'
32
32
  | 'schedule'
33
33
  | 'external_trigger'
34
+ | 'channel_message'
34
35
  | 'approval';
35
36
  targetId: string;
36
37
  targetName?: string;
@@ -65,10 +66,23 @@ export type ParallEvent = {
65
66
  externalConnectionDisplayName?: string;
66
67
  externalIngressEventId?: string;
67
68
  externalIngressEventType?: string;
69
+ /** External IM channel metadata, used for channel_message events. */
70
+ channelProvider?: string;
71
+ channelConversationType?: string;
72
+ /** Provider-side conversation id (the send_message target). */
73
+ channelExternalConversationId?: string;
74
+ /** Provider-side message id (in-thread reply target). */
75
+ channelExternalMessageId?: string;
68
76
  /** Original event timestamp (e.g., message.created_at). When present,
69
77
  * input steps use this instead of server insertion time for ordering. */
70
78
  sentAt?: string;
71
- ackSourceType?: 'message' | 'task_activity' | 'comment' | 'schedule_run' | 'external_trigger_run';
79
+ ackSourceType?:
80
+ | 'message'
81
+ | 'task_activity'
82
+ | 'comment'
83
+ | 'schedule_run'
84
+ | 'external_trigger_run'
85
+ | 'channel_message';
72
86
  ackSourceId?: string;
73
87
  /** Unread message count in the target chat since agent's last interaction. */
74
88
  unreadCount?: number;