@agent-link/agent 0.1.326 → 0.1.328

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/pi.js CHANGED
@@ -14,6 +14,7 @@ import { piCapabilities, } from './backends/types.js';
14
14
  import { CONFIG_DIR } from './config.js';
15
15
  import { getCleanEnv, resetCachedExecutablePath, resolveWindowsNpmWrapper, } from './sdk.js';
16
16
  import { getProvider, resolveProviderCommand } from './providers.js';
17
+ import { providerCommandsMessage, } from './provider-command-types.js';
17
18
  const conversations = new Map();
18
19
  const sendFns = new Set();
19
20
  const THINKING_LEVELS = new Set(['off', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max']);
@@ -23,8 +24,15 @@ const MIME_PATTERN = /^[a-z0-9][a-z0-9!#$&^_.+-]*\/[a-z0-9][a-z0-9!#$&^_.+-]*$/i
23
24
  const IMAGE_MIME_PATTERN = /^image\/(?:png|jpeg|gif|webp)$/i;
24
25
  const TURN_HEARTBEAT_INTERVAL_MS = 15_000;
25
26
  const RPC_TIMEOUT_MS = 30_000;
27
+ const DISCOVERY_TIMEOUT_MS = 15_000;
26
28
  const ABORT_TIMEOUT_MS = 5_000;
27
29
  const MAX_STDERR_CHARS = 64 * 1024;
30
+ const MAX_DISCOVERY_STDOUT_CHARS = 1024 * 1024;
31
+ const MAX_COMMAND_NAME_CHARS = 128;
32
+ const MAX_COMMAND_DESCRIPTION_CHARS = 4096;
33
+ const MAX_COMMAND_METADATA_CHARS = 4096;
34
+ const PI_COMMAND_SOURCES = new Set(['skill', 'prompt', 'extension']);
35
+ const PI_COMMAND_NAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]*$/;
28
36
  const MAX_ATTACHMENTS = 5;
29
37
  const MAX_ATTACHMENT_BYTES = 10 * 1024 * 1024;
30
38
  const PI_SESSION_LIST_LIMIT = 150;
@@ -69,6 +77,8 @@ function createState(conversationId, workDir, providerId = 'pi') {
69
77
  model: null,
70
78
  thinking: null,
71
79
  availableModels: [],
80
+ commands: [],
81
+ commandCatalogGeneration: 0,
72
82
  sessionNotified: false,
73
83
  cancelRequested: false,
74
84
  protocolFailed: false,
@@ -103,6 +113,11 @@ export function rebindPiConversation(piSessionId, newConversationId, providerId
103
113
  conversations.delete(oldConversationId);
104
114
  state.conversationId = newConversationId;
105
115
  conversations.set(newConversationId, state);
116
+ if (state.child) {
117
+ const child = state.child;
118
+ invalidatePiCommands(state);
119
+ void refreshPiCommands(state, child, state.commandCatalogGeneration);
120
+ }
106
121
  return true;
107
122
  }
108
123
  }
@@ -244,10 +259,20 @@ function rejectPendingRpc(state, error) {
244
259
  }
245
260
  state.pendingRpc.clear();
246
261
  }
262
+ function invalidatePiCommands(state) {
263
+ state.commandCatalogGeneration++;
264
+ state.commands = [];
265
+ sendFn(providerCommandsMessage({
266
+ providerId: state.providerId,
267
+ conversationId: state.conversationId,
268
+ workDir: state.workDir,
269
+ }, { commands: [] }));
270
+ }
247
271
  function terminatePiChild(state) {
248
272
  const child = state.child;
249
273
  state.child = null;
250
274
  state.childProviderId = null;
275
+ invalidatePiCommands(state);
251
276
  rejectPendingRpc(state, new Error('PI RPC process was terminated'));
252
277
  if (!child)
253
278
  return;
@@ -324,16 +349,29 @@ function writeRpcRecord(state, record) {
324
349
  function handleRpcResponse(state, record) {
325
350
  const id = record.id;
326
351
  const command = record.command;
352
+ const pendingById = typeof id === 'string' ? state.pendingRpc.get(id) : undefined;
327
353
  if (typeof id !== 'string' || typeof command !== 'string' || typeof record.success !== 'boolean') {
354
+ if (pendingById?.command === 'get_commands') {
355
+ clearTimeout(pendingById.timer);
356
+ state.pendingRpc.delete(id);
357
+ pendingById.reject(new Error('PI RPC get_commands returned a malformed response'));
358
+ return;
359
+ }
328
360
  protocolFailure(state, 'malformed response record');
329
361
  return;
330
362
  }
331
- const pending = state.pendingRpc.get(id);
363
+ const pending = pendingById;
332
364
  if (!pending) {
333
365
  protocolFailure(state, `unexpected response id ${id}`);
334
366
  return;
335
367
  }
336
368
  if (pending.command !== command) {
369
+ if (pending.command === 'get_commands') {
370
+ clearTimeout(pending.timer);
371
+ state.pendingRpc.delete(id);
372
+ pending.reject(new Error(`PI RPC get_commands response command mismatch for ${id}`));
373
+ return;
374
+ }
337
375
  protocolFailure(state, `response command mismatch for ${id}`);
338
376
  return;
339
377
  }
@@ -610,6 +648,84 @@ function attachStrictJsonlReader(state, child) {
610
648
  protocolFailure(state, 'stdout ended with an unterminated record');
611
649
  });
612
650
  }
651
+ function normalizePiCommands(response) {
652
+ const data = asRecord(response.data);
653
+ if (!data || !Array.isArray(data.commands)) {
654
+ throw new Error('PI RPC get_commands returned malformed data');
655
+ }
656
+ const commands = [];
657
+ const seen = new Set();
658
+ for (const value of data.commands) {
659
+ const command = asRecord(value);
660
+ if (!command || !PI_COMMAND_SOURCES.has(command.source))
661
+ continue;
662
+ if (typeof command.name !== 'string'
663
+ || !command.name
664
+ || command.name.length > MAX_COMMAND_NAME_CHARS
665
+ || !PI_COMMAND_NAME_PATTERN.test(command.name)
666
+ || (command.source === 'skill' && !command.name.startsWith('skill:'))
667
+ || (command.source !== 'skill' && command.name.startsWith('skill:')))
668
+ continue;
669
+ if (command.description != null && typeof command.description !== 'string')
670
+ continue;
671
+ if (command.location != null && typeof command.location !== 'string')
672
+ continue;
673
+ if (command.path != null && typeof command.path !== 'string')
674
+ continue;
675
+ const description = typeof command.description === 'string'
676
+ ? command.description.trim().slice(0, MAX_COMMAND_DESCRIPTION_CHARS)
677
+ : '';
678
+ const location = typeof command.location === 'string'
679
+ ? command.location.trim().slice(0, MAX_COMMAND_METADATA_CHARS)
680
+ : '';
681
+ const sourcePath = typeof command.path === 'string'
682
+ ? command.path.slice(0, MAX_COMMAND_METADATA_CHARS)
683
+ : '';
684
+ const key = `${command.source}\u0000${command.name}`;
685
+ if (seen.has(key))
686
+ continue;
687
+ seen.add(key);
688
+ const source = command.source;
689
+ const name = source === 'skill' ? command.name.slice('skill:'.length) : command.name;
690
+ commands.push({
691
+ id: `${source}:${command.name}`,
692
+ name,
693
+ invocation: `/${command.name}`,
694
+ description,
695
+ kind: source,
696
+ ...(location ? { location } : {}),
697
+ ...(sourcePath ? { path: sourcePath } : {}),
698
+ });
699
+ }
700
+ return commands;
701
+ }
702
+ async function refreshPiCommands(state, child, generation) {
703
+ const scope = {
704
+ providerId: state.providerId,
705
+ conversationId: state.conversationId,
706
+ workDir: resolve(state.workDir),
707
+ };
708
+ try {
709
+ const response = await sendRpcCommand(state, { type: 'get_commands' });
710
+ const commands = normalizePiCommands(response);
711
+ if (state.child !== child
712
+ || state.commandCatalogGeneration !== generation
713
+ || state.providerId !== scope.providerId
714
+ || state.conversationId !== scope.conversationId
715
+ || normalizePathForCompare(state.workDir) !== normalizePathForCompare(scope.workDir))
716
+ return;
717
+ state.commands = commands;
718
+ sendFn(providerCommandsMessage(scope, { commands }));
719
+ }
720
+ catch (error) {
721
+ if (state.child !== child || state.commandCatalogGeneration !== generation)
722
+ return;
723
+ state.commands = [];
724
+ // Command discovery is optional. A valid RPC error or malformed command
725
+ // payload must not poison the persistent chat protocol.
726
+ sendFn(providerCommandsMessage(scope, { commands: [], error: error.message }));
727
+ }
728
+ }
613
729
  function directSpawnCommand(command, prefixArgs) {
614
730
  const direct = resolveWindowsNpmWrapper(command);
615
731
  if (direct) {
@@ -655,6 +771,9 @@ async function launchPiProcess(state) {
655
771
  }
656
772
  state.child = child;
657
773
  state.childProviderId = state.providerId;
774
+ state.commandCatalogGeneration++;
775
+ const commandCatalogGeneration = state.commandCatalogGeneration;
776
+ state.commands = [];
658
777
  state.protocolFailed = false;
659
778
  state.stderrBuffer = '';
660
779
  attachStrictJsonlReader(state, child);
@@ -666,6 +785,7 @@ async function launchPiProcess(state) {
666
785
  return;
667
786
  state.child = null;
668
787
  state.childProviderId = null;
788
+ invalidatePiCommands(state);
669
789
  const wrapped = new Error(`Failed to start PI: ${error.message}`);
670
790
  rejectPendingRpc(state, wrapped);
671
791
  if (state.turnActive)
@@ -677,6 +797,7 @@ async function launchPiProcess(state) {
677
797
  return;
678
798
  state.child = null;
679
799
  state.childProviderId = null;
800
+ invalidatePiCommands(state);
680
801
  const diagnostic = state.stderrBuffer.trim();
681
802
  const error = new Error(diagnostic || `PI RPC process exited${code == null ? '' : ` with code ${code}`}`);
682
803
  rejectPendingRpc(state, error);
@@ -704,6 +825,7 @@ async function launchPiProcess(state) {
704
825
  });
705
826
  }
706
827
  void refreshAvailableModels(state);
828
+ void refreshPiCommands(state, child, commandCatalogGeneration);
707
829
  })();
708
830
  try {
709
831
  await state.launching;
@@ -739,6 +861,110 @@ async function refreshAvailableModels(state) {
739
861
  // Model discovery is optional and must not fail an otherwise healthy turn.
740
862
  }
741
863
  }
864
+ export async function discoverPiCommands(conversationId, workDir, providerId = 'pi') {
865
+ const scope = { providerId, conversationId, workDir: resolve(workDir) };
866
+ let child = null;
867
+ try {
868
+ const provider = getProvider(providerId);
869
+ const resolvedCommand = resolveProviderCommand(provider);
870
+ // Pre-prompt discovery is deliberately ephemeral: it avoids allocating a
871
+ // persisted empty Pi session. Resources are loaded again by the eventual
872
+ // persistent conversation process, which publishes an authoritative refresh.
873
+ const direct = directSpawnCommand(resolvedCommand.command, [...resolvedCommand.prefixArgs, '--mode', 'rpc', '--no-session']);
874
+ child = spawn(direct.command, direct.args, {
875
+ cwd: scope.workDir,
876
+ stdio: ['pipe', 'pipe', 'pipe'],
877
+ env: { ...getCleanEnv(), ...(provider.env || {}) },
878
+ ...resolvedCommand.spawnOpts,
879
+ windowsHide: true,
880
+ });
881
+ child.stderr?.on('data', () => { });
882
+ const commandId = `agentlink-discovery-${randomUUID()}`;
883
+ const response = await new Promise((resolveResponse, rejectResponse) => {
884
+ const decoder = new StringDecoder('utf8');
885
+ let pending = '';
886
+ let totalChars = 0;
887
+ let settled = false;
888
+ const finish = (error, responseValue) => {
889
+ if (settled)
890
+ return;
891
+ settled = true;
892
+ clearTimeout(timer);
893
+ if (error)
894
+ rejectResponse(error);
895
+ else
896
+ resolveResponse(responseValue);
897
+ };
898
+ const timer = setTimeout(() => finish(new Error(`PI RPC get_commands timed out after ${DISCOVERY_TIMEOUT_MS}ms`)), DISCOVERY_TIMEOUT_MS);
899
+ timer.unref?.();
900
+ child.on('error', error => finish(new Error(`Failed to start PI discovery: ${error.message}`)));
901
+ child.on('close', code => finish(new Error(`PI discovery process exited${code == null ? '' : ` with code ${code}`}`)));
902
+ child.stdout?.on('data', (chunk) => {
903
+ const text = typeof chunk === 'string' ? chunk : decoder.write(chunk);
904
+ totalChars += text.length;
905
+ if (totalChars > MAX_DISCOVERY_STDOUT_CHARS) {
906
+ finish(new Error('PI discovery output exceeded the protocol limit'));
907
+ return;
908
+ }
909
+ pending += text;
910
+ let newlineIndex = pending.indexOf('\n');
911
+ while (!settled && newlineIndex !== -1) {
912
+ const line = pending.slice(0, newlineIndex);
913
+ pending = pending.slice(newlineIndex + 1);
914
+ if (line.trim()) {
915
+ let record;
916
+ try {
917
+ const value = JSON.parse(line);
918
+ const parsed = asRecord(value);
919
+ if (!parsed)
920
+ throw new Error('record is not an object');
921
+ record = parsed;
922
+ }
923
+ catch (error) {
924
+ finish(new Error(`PI discovery returned malformed JSONL: ${error.message}`));
925
+ return;
926
+ }
927
+ if (record.type === 'response') {
928
+ if (record.id !== commandId
929
+ || record.command !== 'get_commands'
930
+ || typeof record.success !== 'boolean') {
931
+ finish(new Error('PI discovery returned a mismatched response'));
932
+ return;
933
+ }
934
+ if (!record.success) {
935
+ finish(new Error(typeof record.error === 'string' ? record.error : 'PI RPC get_commands failed'));
936
+ return;
937
+ }
938
+ finish(undefined, record);
939
+ return;
940
+ }
941
+ }
942
+ newlineIndex = pending.indexOf('\n');
943
+ }
944
+ });
945
+ child.stdin?.write(`${JSON.stringify({ type: 'get_commands', id: commandId })}\n`, 'utf8', (error) => {
946
+ if (error)
947
+ finish(error);
948
+ });
949
+ });
950
+ return { commands: normalizePiCommands(response) };
951
+ }
952
+ catch (error) {
953
+ return { commands: [], error: error.message };
954
+ }
955
+ finally {
956
+ if (child) {
957
+ try {
958
+ child.stdin?.end();
959
+ }
960
+ catch { /* already closed */ }
961
+ try {
962
+ child.kill('SIGTERM');
963
+ }
964
+ catch { /* already exited */ }
965
+ }
966
+ }
967
+ }
742
968
  export function getPiAvailableModels(providerId = 'pi') {
743
969
  return [...new Set([...conversations.values()]
744
970
  .filter(state => state.providerId === providerId)
@@ -871,19 +1097,23 @@ export function handlePiChat(conversationId, text, workDir, options, files) {
871
1097
  emitPiError(state, 'PI is already processing this conversation.');
872
1098
  return;
873
1099
  }
874
- state.workDir = resolve(workDir);
1100
+ const resolvedWorkDir = resolve(workDir);
1101
+ const workDirChanged = normalizePathForCompare(state.workDir) !== normalizePathForCompare(resolvedWorkDir);
875
1102
  const requestedProviderId = options?.providerId || state.providerId;
876
1103
  const providerChanged = state.providerId !== requestedProviderId;
877
- if (providerChanged) {
1104
+ if (providerChanged || workDirChanged) {
878
1105
  if (state.child)
879
1106
  terminatePiChild(state);
1107
+ state.workDir = resolvedWorkDir;
1108
+ state.commands = [];
880
1109
  state.piSessionId = null;
881
1110
  state.lastPiSessionId = null;
882
1111
  state.sessionFile = null;
883
1112
  state.sessionNotified = false;
884
1113
  state.model = null;
885
1114
  state.thinking = null;
886
- state.availableModels = [];
1115
+ if (providerChanged)
1116
+ state.availableModels = [];
887
1117
  }
888
1118
  else if (state.child && state.childProviderId !== requestedProviderId) {
889
1119
  terminatePiChild(state);