@sema-agent/client-core 0.83.0 → 0.83.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,429 @@
1
+ import { displayUntrusted } from './displayUntrusted.js';
2
+ export const ENGINE_FIRED_HOOK_EVENTS = [
3
+ 'PreToolUse',
4
+ 'PostToolUse',
5
+ 'PostToolUseFailure',
6
+ 'PostToolBatch',
7
+ 'PreCompact',
8
+ 'PostCompact',
9
+ 'Stop',
10
+ 'StopFailure',
11
+ 'UserPromptSubmit',
12
+ ];
13
+ export const PLUGIN_HOOK_DISPOSITIONS = ['engine', 'shell', 'not_run'];
14
+ export const PLUGIN_HOOK_REASONS = [
15
+ 'projected',
16
+ 'shell_event',
17
+ 'no_executor',
18
+ 'engine_not_local',
19
+ 'engine_locality_unknown',
20
+ 'engine_capability_absent',
21
+ 'engine_capability_unknown',
22
+ 'not_command',
23
+ 'sensitive_options',
24
+ 'engine_limit',
25
+ 'shell_form_user_config',
26
+ 'user_config_unset',
27
+ 'malformed_entry',
28
+ ];
29
+ export const PLUGIN_HOOK_EXCLUSION_REASONS = [
30
+ 'workspace_untrusted',
31
+ 'governance_unreadable',
32
+ 'policy_disable_all',
33
+ 'startup_mode',
34
+ 'managed_plugins_only',
35
+ ];
36
+ export const SETTINGS_HOOK_DROP_REASONS = ['exec_form_unsupported', 'mcp_tool_unsupported'];
37
+ export const ENGINE_HOOK_LIMITS = { maxGroupsPerEvent: 16, maxEntriesPerEvent: 32, maxCommandChars: 8192 };
38
+ const ENGINE_EVENTS = new Set(ENGINE_FIRED_HOOK_EVENTS);
39
+ const USER_CONFIG_REF_ONE = /\$\{user_config\.[^}]+\}/;
40
+ const USER_CONFIG_REF_ALL = /\$\{user_config\.([^}]+)\}/g;
41
+ function isRecord(v) {
42
+ return typeof v === 'object' && v !== null && !Array.isArray(v);
43
+ }
44
+ function nonEmptyString(v) {
45
+ return typeof v === 'string' && v.length > 0;
46
+ }
47
+ export function isEngineFiredHookEvent(event) {
48
+ return ENGINE_EVENTS.has(event);
49
+ }
50
+ function declaresSensitive(options) {
51
+ if (options === undefined)
52
+ return false;
53
+ if (!isRecord(options))
54
+ return true;
55
+ return Object.values(options).some((o) => !isRecord(o) || o.sensitive !== false);
56
+ }
57
+ function savedPlainValue(options, key) {
58
+ if (!isRecord(options) || !Object.hasOwn(options, key))
59
+ return undefined;
60
+ const o = options[key];
61
+ if (!isRecord(o) || o.sensitive !== false)
62
+ return undefined;
63
+ const v = o.value;
64
+ return v === undefined || v === null ? undefined : String(v);
65
+ }
66
+ function declaredSensitiveKey(options, key) {
67
+ if (!isRecord(options) || !Object.hasOwn(options, key))
68
+ return false;
69
+ const o = options[key];
70
+ return !isRecord(o) || o.sensitive !== false;
71
+ }
72
+ function optionEnvKey(key) {
73
+ return key.replace(/[^A-Za-z0-9_]/g, '_').toUpperCase();
74
+ }
75
+ function projectedOptions(options) {
76
+ if (!isRecord(options))
77
+ return undefined;
78
+ const out = {};
79
+ let any = false;
80
+ for (const key of Object.keys(options)) {
81
+ const v = savedPlainValue(options, key);
82
+ if (v === undefined)
83
+ continue;
84
+ out[optionEnvKey(key)] = v;
85
+ any = true;
86
+ }
87
+ return any ? out : undefined;
88
+ }
89
+ function substituteUserConfig(s, options) {
90
+ return s.replace(USER_CONFIG_REF_ALL, (whole, key) => savedPlainValue(options, key) ?? whole);
91
+ }
92
+ function classifyEntry(entry, options) {
93
+ if (!isRecord(entry) || typeof entry.type !== 'string')
94
+ return { kind: 'malformed' };
95
+ if (entry.type !== 'command')
96
+ return { kind: 'other' };
97
+ const command = entry.command;
98
+ if (!nonEmptyString(command))
99
+ return { kind: 'malformed' };
100
+ const args = entry.args;
101
+ if (args === undefined)
102
+ return { kind: 'shell_form', refusesUserConfig: USER_CONFIG_REF_ONE.test(command) };
103
+ if (!Array.isArray(args) || !args.every((a) => typeof a === 'string'))
104
+ return { kind: 'malformed' };
105
+ const unset = [command, ...args].some((s) => [...s.matchAll(USER_CONFIG_REF_ALL)].some((mm) => mm[1] !== undefined && !declaredSensitiveKey(options, mm[1]) && savedPlainValue(options, mm[1]) === undefined));
106
+ return { kind: 'exec_form', command, args, unsetUserConfig: unset };
107
+ }
108
+ export function occupancyOf(wire) {
109
+ const out = new Map();
110
+ for (const [event, groups] of Object.entries(wire)) {
111
+ if (!Array.isArray(groups))
112
+ continue;
113
+ let entries = 0;
114
+ for (const g of groups)
115
+ if (isRecord(g) && Array.isArray(g.hooks))
116
+ entries += g.hooks.length;
117
+ out.set(event, { groups: groups.length, entries });
118
+ }
119
+ return out;
120
+ }
121
+ export function decidePluginHooks(input) {
122
+ const verdicts = [];
123
+ const excluded = [];
124
+ const candidates = new Map();
125
+ let unreadable = 0;
126
+ const fallback = (event, reason) => input.shellHookEvents.has(event) ? { disposition: 'shell', reason } : { disposition: 'not_run', reason };
127
+ input.plugins.forEach((p, pluginIndex) => {
128
+ if (!isRecord(p) || !isRecord(p.hooks)) {
129
+ unreadable++;
130
+ return;
131
+ }
132
+ const src = p;
133
+ const sourceOk = nonEmptyString(p.id) && nonEmptyString(p.name) && nonEmptyString(p.root) && nonEmptyString(p.dataDir);
134
+ const pluginId = typeof p.id === 'string' ? p.id : '';
135
+ const pluginName = typeof p.name === 'string' ? p.name : '';
136
+ for (const [event, groups] of Object.entries(p.hooks)) {
137
+ if (!Array.isArray(groups)) {
138
+ unreadable++;
139
+ continue;
140
+ }
141
+ ;
142
+ groups.forEach((group, groupIndex) => {
143
+ if (!isRecord(group) || !Array.isArray(group.hooks)) {
144
+ unreadable++;
145
+ return;
146
+ }
147
+ ;
148
+ group.hooks.forEach((entry, hookIndex) => {
149
+ const key = { pluginId, pluginName, event, groupIndex, hookIndex };
150
+ if (input.exclusion !== undefined) {
151
+ excluded.push({ ...key, reason: input.exclusion });
152
+ return;
153
+ }
154
+ if (input.managedOnly && p.managed !== true) {
155
+ excluded.push({ ...key, reason: 'managed_plugins_only' });
156
+ return;
157
+ }
158
+ const shape = sourceOk ? classifyEntry(entry, src.options) : { kind: 'malformed' };
159
+ if (shape.kind === 'malformed') {
160
+ verdicts.push({ ...key, disposition: 'not_run', reason: 'malformed_entry' });
161
+ return;
162
+ }
163
+ if (shape.kind === 'shell_form' && shape.refusesUserConfig) {
164
+ verdicts.push({ ...key, disposition: 'not_run', reason: 'shell_form_user_config' });
165
+ return;
166
+ }
167
+ if (shape.kind === 'exec_form' && shape.unsetUserConfig) {
168
+ verdicts.push({ ...key, disposition: 'not_run', reason: 'user_config_unset' });
169
+ return;
170
+ }
171
+ if (!ENGINE_EVENTS.has(event)) {
172
+ verdicts.push({
173
+ ...key,
174
+ ...(input.shellHookEvents.has(event)
175
+ ? { disposition: 'shell', reason: 'shell_event' }
176
+ : { disposition: 'not_run', reason: 'no_executor' }),
177
+ });
178
+ return;
179
+ }
180
+ const blocked = input.engine.ownedByThisShell === null
181
+ ? 'engine_locality_unknown'
182
+ : !input.engine.ownedByThisShell
183
+ ? 'engine_not_local'
184
+ : input.engine.pluginHooksCapable === null
185
+ ? 'engine_capability_unknown'
186
+ : !input.engine.pluginHooksCapable
187
+ ? 'engine_capability_absent'
188
+ : shape.kind === 'other'
189
+ ? 'not_command'
190
+ : declaresSensitive(src.options)
191
+ ? 'sensitive_options'
192
+ : undefined;
193
+ if (blocked !== undefined) {
194
+ verdicts.push({ ...key, ...fallback(event, blocked) });
195
+ return;
196
+ }
197
+ const plugin = { name: src.name, root: src.root, dataDir: src.dataDir };
198
+ const options = projectedOptions(src.options);
199
+ if (options !== undefined)
200
+ plugin.options = options;
201
+ const wireEntry = { ...entry, plugin };
202
+ if (shape.kind === 'exec_form') {
203
+ wireEntry.command = substituteUserConfig(shape.command, src.options);
204
+ wireEntry.args = shape.args.map((a) => substituteUserConfig(a, src.options));
205
+ }
206
+ const verdict = { ...key, disposition: 'engine', reason: 'projected' };
207
+ verdicts.push(verdict);
208
+ const list = candidates.get(event) ?? [];
209
+ list.push({ verdict, pluginIndex, group, wireEntry });
210
+ candidates.set(event, list);
211
+ });
212
+ });
213
+ }
214
+ });
215
+ const projected = new Map();
216
+ for (const [event, list] of candidates) {
217
+ const fits = list.filter((c) => {
218
+ const cmd = c.wireEntry.command;
219
+ if (typeof cmd === 'string' && cmd.length <= ENGINE_HOOK_LIMITS.maxCommandChars)
220
+ return true;
221
+ Object.assign(c.verdict, fallback(event, 'engine_limit'));
222
+ return false;
223
+ });
224
+ if (fits.length === 0)
225
+ continue;
226
+ const groupKeys = [];
227
+ for (const c of fits) {
228
+ const k = `${c.pluginIndex}\u0000${c.verdict.groupIndex}`;
229
+ if (!groupKeys.includes(k))
230
+ groupKeys.push(k);
231
+ }
232
+ const occ = input.occupied.get(event) ?? { groups: 0, entries: 0 };
233
+ if (occ.groups + groupKeys.length > ENGINE_HOOK_LIMITS.maxGroupsPerEvent ||
234
+ occ.entries + fits.length > ENGINE_HOOK_LIMITS.maxEntriesPerEvent) {
235
+ for (const c of fits)
236
+ Object.assign(c.verdict, fallback(event, 'engine_limit'));
237
+ continue;
238
+ }
239
+ const groupsOut = [];
240
+ for (const k of groupKeys) {
241
+ const members = fits.filter((c) => `${c.pluginIndex}\u0000${c.verdict.groupIndex}` === k);
242
+ const first = members[0];
243
+ if (first === undefined)
244
+ continue;
245
+ groupsOut.push({ ...first.group, hooks: members.map((c) => c.wireEntry) });
246
+ }
247
+ projected.set(event, groupsOut);
248
+ }
249
+ return { verdicts, excluded, projected, unreadable };
250
+ }
251
+ const SHELL_INERT_COMMAND = /^[A-Za-z0-9_./:+-]+$/;
252
+ function carriesArgs(entry) {
253
+ const args = entry.args;
254
+ if (args === undefined || args === null)
255
+ return false;
256
+ if (Array.isArray(args) && args.length === 0) {
257
+ const command = entry.command;
258
+ return !(typeof command === 'string' && SHELL_INERT_COMMAND.test(command));
259
+ }
260
+ return true;
261
+ }
262
+ export function filterSettingsHookGroups(source, event, groups, execFormCapable, hostFires, failLoudMcpTool) {
263
+ const drops = [];
264
+ const out = [];
265
+ let stripped = 0;
266
+ const engineEvent = ENGINE_EVENTS.has(event);
267
+ const notRun = engineEvent && !hostFires;
268
+ const keepMcpTool = failLoudMcpTool && engineEvent;
269
+ groups.forEach((group, groupIndex) => {
270
+ if (!isRecord(group) || !Array.isArray(group.hooks)) {
271
+ out.push(group);
272
+ return;
273
+ }
274
+ const kept = [];
275
+ let changed = false;
276
+ group.hooks.forEach((entry, hookIndex) => {
277
+ const reason = !isRecord(entry)
278
+ ? undefined
279
+ : entry.type === 'mcp_tool'
280
+ ? keepMcpTool
281
+ ? undefined
282
+ : 'mcp_tool_unsupported'
283
+ : entry.type === 'command' && carriesArgs(entry) && !execFormCapable
284
+ ? 'exec_form_unsupported'
285
+ : undefined;
286
+ if (reason !== undefined) {
287
+ drops.push({ source, event, groupIndex, hookIndex, reason, notRun });
288
+ changed = true;
289
+ return;
290
+ }
291
+ if (isRecord(entry) && Object.hasOwn(entry, 'plugin')) {
292
+ const copy = { ...entry };
293
+ delete copy.plugin;
294
+ kept.push(copy);
295
+ stripped++;
296
+ changed = true;
297
+ return;
298
+ }
299
+ kept.push(entry);
300
+ });
301
+ if (!changed)
302
+ out.push(group);
303
+ else if (kept.length > 0)
304
+ out.push({ ...group, hooks: kept });
305
+ });
306
+ return { groups: out, drops, stripped };
307
+ }
308
+ export function pluginHookVerdictOf(plan, pluginId, event, groupIndex, hookIndex) {
309
+ return plan.verdicts.find((v) => v.pluginId === pluginId && v.event === event && v.groupIndex === groupIndex && v.hookIndex === hookIndex);
310
+ }
311
+ const REASON_TEXT = {
312
+ projected: 'it is sent to the engine',
313
+ shell_event: 'this client runs this event itself',
314
+ no_executor: 'neither the engine nor this client runs this event',
315
+ engine_not_local: 'the engine was not started by this client on this machine, so it cannot reach the plugin files',
316
+ engine_locality_unknown: 'it is not known whether the engine was started by this client on this machine, so plugin hooks are not sent to it',
317
+ engine_capability_absent: 'the connected engine does not support plugin hooks',
318
+ engine_capability_unknown: 'it is not known yet whether the connected engine supports plugin hooks, so they are not sent to it',
319
+ not_command: 'only command hooks from plugins can run in the engine',
320
+ sensitive_options: 'the plugin has sensitive options, and those are never sent to the engine',
321
+ engine_limit: "sending it would exceed the engine's hook limits for this event",
322
+ shell_form_user_config: 'its command uses ${user_config.…} in shell form, which is refused (use exec form with "args", or read $CLAUDE_PLUGIN_OPTION_<KEY>)',
323
+ user_config_unset: 'its command uses a plugin option that has not been set',
324
+ malformed_entry: 'the hook entry is malformed',
325
+ };
326
+ const DROP_TEXT = {
327
+ exec_form_unsupported: 'exec-form hooks (with "args") are sent to the engine only once it reports support for them',
328
+ mcp_tool_unsupported: 'MCP tool hooks are not supported by the engine',
329
+ };
330
+ const SOURCE_TEXT = {
331
+ policySettings: 'Managed settings',
332
+ userSettings: 'User settings',
333
+ projectSettings: 'Project settings',
334
+ localSettings: 'Local settings',
335
+ };
336
+ export function pluginHookReasonText(reason) {
337
+ return REASON_TEXT[reason];
338
+ }
339
+ export function settingsHookDropText(reason) {
340
+ return DROP_TEXT[reason];
341
+ }
342
+ export function pluginHookExecutorLabel(v) {
343
+ switch (v.disposition) {
344
+ case 'engine':
345
+ return 'runs in the engine';
346
+ case 'shell':
347
+ return 'runs in this client';
348
+ case 'not_run':
349
+ return `does not run: ${pluginHookReasonText(v.reason)}`;
350
+ }
351
+ }
352
+ const NOTICE_LABEL_MAX = 120;
353
+ const noticeLabel = (s) => capNoticeLabel(escapeNoticeQuotes(displayUntrusted(s, { credentialUrls: false, credentialWords: false })));
354
+ function escapeNoticeQuotes(s) {
355
+ const budget = NOTICE_LABEL_MAX + 16;
356
+ const out = [];
357
+ let len = 0;
358
+ let i = 0;
359
+ while (i < s.length && len <= budget) {
360
+ const c = s.charCodeAt(i);
361
+ if (c === 0x5c) {
362
+ let j = i;
363
+ while (j < s.length && s.charCodeAt(j) === 0x5c)
364
+ j++;
365
+ const want = s.charCodeAt(j) === 0x22 ? (j - i) * 2 : j - i;
366
+ const take = Math.min(want, budget + 1 - len);
367
+ out.push('\\'.repeat(take));
368
+ len += take;
369
+ i = j;
370
+ continue;
371
+ }
372
+ if (c === 0x22) {
373
+ out.push('\\"');
374
+ len += 2;
375
+ }
376
+ else {
377
+ out.push(s.charAt(i));
378
+ len++;
379
+ }
380
+ i++;
381
+ }
382
+ return out.join('');
383
+ }
384
+ function capNoticeLabel(s) {
385
+ if (s.length <= NOTICE_LABEL_MAX)
386
+ return s;
387
+ const token = /\\u\{[0-9A-F]{1,6}\}|\\u[0-9A-F]{4}|\\[\\"]|[\uD800-\uDBFF][\uDC00-\uDFFF]|[^]/gy;
388
+ let end = 0;
389
+ for (let t = token.exec(s); t !== null; t = token.exec(s)) {
390
+ if (t.index + t[0].length > NOTICE_LABEL_MAX - 1)
391
+ break;
392
+ end = t.index + t[0].length;
393
+ }
394
+ return `${s.slice(0, end)}…`;
395
+ }
396
+ export function hooksNotRunNotice(plan) {
397
+ const lines = [];
398
+ const byPlugin = new Map();
399
+ for (const v of plan.verdicts) {
400
+ if (v.disposition !== 'not_run')
401
+ continue;
402
+ const k = `${v.pluginId}\u0000${v.reason}`;
403
+ const row = byPlugin.get(k) ?? { name: v.pluginName, reason: v.reason, events: [], count: 0 };
404
+ if (!row.events.includes(v.event))
405
+ row.events.push(v.event);
406
+ row.count++;
407
+ byPlugin.set(k, row);
408
+ }
409
+ for (const row of byPlugin.values()) {
410
+ const events = row.events.map((e) => noticeLabel(e)).join(', ');
411
+ lines.push(`Plugin "${noticeLabel(row.name) || '(unnamed)'}": ${events} ${row.count === 1 ? 'hook' : 'hooks'} will not run — ${pluginHookReasonText(row.reason)}.`);
412
+ }
413
+ const bySource = new Map();
414
+ for (const d of plan.settingsDropped) {
415
+ if (!d.notRun)
416
+ continue;
417
+ const k = `${d.source}\u0000${d.reason}`;
418
+ const row = bySource.get(k) ?? { source: d.source, reason: d.reason, events: [], count: 0 };
419
+ if (!row.events.includes(d.event))
420
+ row.events.push(d.event);
421
+ row.count++;
422
+ bySource.set(k, row);
423
+ }
424
+ for (const row of bySource.values()) {
425
+ const events = row.events.map((e) => noticeLabel(e)).join(', ');
426
+ lines.push(`${SOURCE_TEXT[row.source]}: ${events} ${row.count === 1 ? 'hook' : 'hooks'} will not run — ${settingsHookDropText(row.reason)}.`);
427
+ }
428
+ return lines;
429
+ }
@@ -1,5 +1,6 @@
1
1
  import { hostLog } from '../host.js';
2
2
  import { resumeRetryLaterFromError } from '../wireErrorTriage.js';
3
+ import { displayUntrusted } from '../displayUntrusted.js';
3
4
  import { engineCapTrue } from '../engineCapsCache.js';
4
5
  import { makeEngineWireClient } from '../engineWireSdk.js';
5
6
  import { engineWireTargetFor } from '../engineWireTarget.js';
@@ -99,7 +100,7 @@ export function resolveSubagentResumeContext(childTaskId) {
99
100
  return { facade: client.runs, runId: resolveOwnerRunId(childTaskId), session };
100
101
  }
101
102
  function clean(v, max = 200) {
102
- return String(v ?? '').replace(/[\x00-\x1f\x7f]/g, '.').slice(0, max);
103
+ return displayUntrusted(String(v ?? ''), { credentialUrls: false, credentialWords: false, foldLines: false, mark: 'dot', max });
103
104
  }
104
105
  export async function resumeSettledSubagent(facade, runId, target, content, opts) {
105
106
  if (facade === null)