@softov/ahpc 0.2.0 → 0.3.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.
@@ -20,6 +20,8 @@
20
20
  */
21
21
  import { SessionFlag } from '../ahp/types.js';
22
22
  import { spoken, turn as runTurn, until } from '../wait.js';
23
+ /** Every group there is, for `--mcp-tools` to name in its help. */
24
+ export const GROUPS = ['resources', 'terminals', 'automations', 'changes'];
23
25
  const text = (value) => (typeof value === 'string' ? value : '');
24
26
  const uriOf = (input) => {
25
27
  const found = text(input.session);
@@ -42,6 +44,19 @@ const said = (one) => ({
42
44
  : null))
43
45
  .filter((one_) => one_ !== null),
44
46
  });
47
+ /** The URI a resource tool was given, which is a `file://` on the host rather than a local path. */
48
+ const pathOf = (input, key = 'path') => {
49
+ const found = text(input[key]);
50
+ if (found === '')
51
+ throw new Error(`No ${key} was given. Resource tools take a file:// URI on the host, not a path on this machine.`);
52
+ return found;
53
+ };
54
+ /** A host half this connection may not have, or the reason it does not. */
55
+ function has(part, what) {
56
+ if (part === undefined)
57
+ throw new Error(`This host serves no ${what}. A host is given one, and this one was not.`);
58
+ return part;
59
+ }
45
60
  export const TOOLS = [
46
61
  {
47
62
  name: 'list_sessions',
@@ -191,11 +206,23 @@ export const TOOLS = [
191
206
  required: ['session', 'text'],
192
207
  additionalProperties: false,
193
208
  },
194
- run: async (host, input) => {
209
+ run: async (host, input, report) => {
195
210
  const model = text(input.model);
196
211
  const answer = await runTurn(host, uriOf(input), text(input.text), {
197
212
  ...(model === '' ? {} : { model: { id: model } }),
198
213
  ...(typeof input.timeoutSeconds === 'number' ? { timeoutSeconds: input.timeoutSeconds } : {}),
214
+ /*
215
+ * What a caller watching this is told while it waits.
216
+ *
217
+ * The tool a session stopped on, not the reply as it is typed: MCP's
218
+ * progress carries a human-readable line and has no shape for partial
219
+ * result content, so the text still arrives whole at the end. What
220
+ * this fixes is an agent that looked frozen for a minute.
221
+ */
222
+ ...(report === undefined ? {} : {
223
+ onStep: (call) => report(call.name),
224
+ onWaiting: (call) => report(`waiting on ${call.name}`),
225
+ }),
199
226
  });
200
227
  // Not an error: the turn is still running and the session is still
201
228
  // there, which is a different thing to tell a caller than a failure.
@@ -323,6 +350,416 @@ export const TOOLS = [
323
350
  return { answered: true };
324
351
  },
325
352
  },
353
+ /*
354
+ * The files the host serves, which is the `resources` group.
355
+ *
356
+ * Every one of these takes a `file://` URI on the *host*, not a path here -
357
+ * the host may be on another machine, and a tool that quietly resolved a
358
+ * relative path against this process's directory would be wrong in a way
359
+ * nobody notices until it writes somewhere.
360
+ */
361
+ {
362
+ name: 'list_directory',
363
+ group: 'resources',
364
+ title: 'List a directory',
365
+ description: 'What is in a directory the host serves. Takes a file:// URI on the host.',
366
+ readOnly: true,
367
+ input: {
368
+ type: 'object',
369
+ properties: { path: { type: 'string', description: 'A file:// URI of a directory on the host.' } },
370
+ required: ['path'],
371
+ additionalProperties: false,
372
+ },
373
+ run: async (host, input) => has(host.resourceList, 'filesystem')(pathOf(input)),
374
+ },
375
+ {
376
+ name: 'read_file',
377
+ group: 'resources',
378
+ title: 'Read a file',
379
+ description: 'The contents of a file the host serves. Text comes back as text; anything the host sends as bytes comes back base64 with the encoding said.',
380
+ readOnly: true,
381
+ input: {
382
+ type: 'object',
383
+ properties: { path: { type: 'string', description: 'A file:// URI on the host.' } },
384
+ required: ['path'],
385
+ additionalProperties: false,
386
+ },
387
+ run: async (host, input) => has(host.resourceRead, 'filesystem')(pathOf(input)),
388
+ },
389
+ {
390
+ name: 'write_file',
391
+ group: 'resources',
392
+ title: 'Write a file',
393
+ description: 'Write a file on the host, refusing if it changed since it was read. Pass force to write over whatever is there now. A host that has not granted write access to that directory refuses this, and only a person at the host can grant it.',
394
+ readOnly: false,
395
+ input: {
396
+ type: 'object',
397
+ properties: {
398
+ path: { type: 'string', description: 'A file:// URI on the host.' },
399
+ content: { type: 'string', description: 'The whole new contents. This replaces the file.' },
400
+ createOnly: { type: 'boolean', description: 'Refuse if the file already exists.' },
401
+ force: { type: 'boolean', description: 'Write even if the file changed since it was last read.' },
402
+ },
403
+ required: ['path', 'content'],
404
+ additionalProperties: false,
405
+ },
406
+ run: async (host, input) => {
407
+ const write = has(host.resourceWrite, 'writable filesystem');
408
+ const uri = pathOf(input);
409
+ /*
410
+ * The etag the file has now, unless told not to.
411
+ *
412
+ * The same guard `ahpc resource write` has, and it matters more here: a
413
+ * model reads a file, thinks about it, and writes it back, and the whole
414
+ * of that is a read-modify-write with a person editing in between. A
415
+ * write with no `ifMatch` lands on whatever is there and loses their edit.
416
+ */
417
+ let ifMatch;
418
+ if (input.force !== true && host.resourceResolve) {
419
+ try {
420
+ ifMatch = (await host.resourceResolve(uri)).etag;
421
+ }
422
+ catch { /* not there yet, so there is nothing to have changed */ }
423
+ }
424
+ await write(uri, text(input.content), {
425
+ ...(input.createOnly === true ? { createOnly: true } : {}),
426
+ ...(ifMatch === undefined ? {} : { ifMatch }),
427
+ });
428
+ return { written: uri };
429
+ },
430
+ },
431
+ {
432
+ name: 'make_directory',
433
+ group: 'resources',
434
+ title: 'Make a directory',
435
+ description: 'Create a directory on the host.',
436
+ readOnly: false,
437
+ input: {
438
+ type: 'object',
439
+ properties: { path: { type: 'string' } },
440
+ required: ['path'],
441
+ additionalProperties: false,
442
+ },
443
+ run: async (host, input) => {
444
+ await has(host.resourceMkdir, 'writable filesystem')(pathOf(input));
445
+ return { made: pathOf(input) };
446
+ },
447
+ },
448
+ {
449
+ name: 'delete_path',
450
+ group: 'resources',
451
+ title: 'Delete a file or directory',
452
+ description: 'Remove something on the host. A directory needs recursive.',
453
+ readOnly: false,
454
+ input: {
455
+ type: 'object',
456
+ properties: {
457
+ path: { type: 'string' },
458
+ recursive: { type: 'boolean', description: 'Needed to remove a directory that is not empty.' },
459
+ },
460
+ required: ['path'],
461
+ additionalProperties: false,
462
+ },
463
+ run: async (host, input) => {
464
+ await has(host.resourceDelete, 'writable filesystem')(pathOf(input), {
465
+ ...(input.recursive === true ? { recursive: true } : {}),
466
+ });
467
+ return { deleted: pathOf(input) };
468
+ },
469
+ },
470
+ {
471
+ name: 'move_path',
472
+ group: 'resources',
473
+ title: 'Move or rename',
474
+ description: 'Move something on the host, which is also how it is renamed.',
475
+ readOnly: false,
476
+ input: {
477
+ type: 'object',
478
+ properties: {
479
+ from: { type: 'string' },
480
+ to: { type: 'string' },
481
+ failIfExists: { type: 'boolean', description: 'Refuse rather than write over something already at the destination.' },
482
+ },
483
+ required: ['from', 'to'],
484
+ additionalProperties: false,
485
+ },
486
+ run: async (host, input) => {
487
+ await has(host.resourceMove, 'writable filesystem')(pathOf(input, 'from'), pathOf(input, 'to'), {
488
+ ...(input.failIfExists === true ? { failIfExists: true } : {}),
489
+ });
490
+ return { moved: pathOf(input, 'to') };
491
+ },
492
+ },
493
+ {
494
+ name: 'copy_path',
495
+ group: 'resources',
496
+ title: 'Copy',
497
+ description: 'Copy something on the host.',
498
+ readOnly: false,
499
+ input: {
500
+ type: 'object',
501
+ properties: {
502
+ from: { type: 'string' },
503
+ to: { type: 'string' },
504
+ failIfExists: { type: 'boolean', description: 'Refuse rather than write over something already at the destination.' },
505
+ },
506
+ required: ['from', 'to'],
507
+ additionalProperties: false,
508
+ },
509
+ run: async (host, input) => {
510
+ await has(host.resourceCopy, 'writable filesystem')(pathOf(input, 'from'), pathOf(input, 'to'), {
511
+ ...(input.failIfExists === true ? { failIfExists: true } : {}),
512
+ });
513
+ return { copied: pathOf(input, 'to') };
514
+ },
515
+ },
516
+ /* The host's terminals, which is the `terminals` group. */
517
+ {
518
+ name: 'list_terminals',
519
+ group: 'terminals',
520
+ title: 'List terminals',
521
+ description: 'The terminals the host is running, with the URI each other terminal tool takes.',
522
+ readOnly: true,
523
+ input: { type: 'object', properties: {}, additionalProperties: false },
524
+ run: async (host) => (await host.terminals()).map((row) => ({
525
+ terminal: row.resource,
526
+ title: row.title,
527
+ ...(row.exitCode === undefined ? {} : { exitCode: row.exitCode }),
528
+ })),
529
+ },
530
+ {
531
+ name: 'new_terminal',
532
+ group: 'terminals',
533
+ title: 'Open a terminal',
534
+ description: 'Start a terminal on the host and return its URI.',
535
+ readOnly: false,
536
+ input: {
537
+ type: 'object',
538
+ properties: {
539
+ workingDirectory: { type: 'string', description: 'An absolute path on the host.' },
540
+ name: { type: 'string' },
541
+ },
542
+ additionalProperties: false,
543
+ },
544
+ run: async (host, input) => ({
545
+ terminal: await host.createTerminal({
546
+ ...(text(input.workingDirectory) === '' ? {} : { cwd: text(input.workingDirectory) }),
547
+ ...(text(input.name) === '' ? {} : { name: text(input.name) }),
548
+ }),
549
+ }),
550
+ },
551
+ {
552
+ name: 'send_to_terminal',
553
+ group: 'terminals',
554
+ title: 'Type into a terminal',
555
+ description: 'Send a line to a terminal. A newline is added unless newline is false, because a shell runs lines rather than strings.',
556
+ readOnly: false,
557
+ input: {
558
+ type: 'object',
559
+ properties: {
560
+ terminal: { type: 'string', description: 'A terminal URI from list_terminals or new_terminal.' },
561
+ text: { type: 'string' },
562
+ newline: { type: 'boolean', description: 'Whether to end it with a newline. True unless said otherwise.' },
563
+ },
564
+ required: ['terminal', 'text'],
565
+ additionalProperties: false,
566
+ },
567
+ run: async (host, input) => {
568
+ host.writeTerminal(pathOf(input, 'terminal'), `${text(input.text)}${input.newline === false ? '' : '\n'}`);
569
+ return { sent: true };
570
+ },
571
+ },
572
+ {
573
+ name: 'read_terminal',
574
+ group: 'terminals',
575
+ title: 'Read a terminal',
576
+ description: 'What a terminal has written so far. With waitSeconds it keeps reading until the process exits or that long passes, which is how a command that was just sent is waited on.',
577
+ readOnly: true,
578
+ input: {
579
+ type: 'object',
580
+ properties: {
581
+ terminal: { type: 'string' },
582
+ waitSeconds: { type: 'number', description: 'Wait this long for the process to exit before answering. Zero, the default, answers with what is there now.' },
583
+ },
584
+ required: ['terminal'],
585
+ additionalProperties: false,
586
+ },
587
+ run: async (host, input, report) => {
588
+ const uri = pathOf(input, 'terminal');
589
+ const seconds = typeof input.waitSeconds === 'number' && input.waitSeconds > 0 ? input.waitSeconds : 0;
590
+ return new Promise((done) => {
591
+ let last;
592
+ const stop = () => {
593
+ clearTimeout(timer);
594
+ handle.close();
595
+ done({
596
+ terminal: uri,
597
+ title: last?.title ?? '',
598
+ output: last?.output ?? '',
599
+ ...(last?.exitCode === undefined ? { running: true } : { exitCode: last.exitCode }),
600
+ });
601
+ };
602
+ const timer = setTimeout(stop, Math.max(0, seconds) * 1000);
603
+ timer.unref?.();
604
+ const handle = host.watchTerminal(uri, (state) => {
605
+ last = state;
606
+ report?.(state.exitCode === undefined ? `${state.output.length} bytes` : `exited ${state.exitCode}`);
607
+ // The first state carries the whole buffer, so a caller that is not
608
+ // waiting has its answer as soon as one arrives.
609
+ if (seconds === 0 || state.exitCode !== undefined)
610
+ stop();
611
+ });
612
+ });
613
+ },
614
+ },
615
+ {
616
+ name: 'dispose_terminal',
617
+ group: 'terminals',
618
+ title: 'Close a terminal',
619
+ description: 'Close a terminal on the host.',
620
+ readOnly: false,
621
+ input: {
622
+ type: 'object',
623
+ properties: { terminal: { type: 'string' } },
624
+ required: ['terminal'],
625
+ additionalProperties: false,
626
+ },
627
+ run: async (host, input) => {
628
+ await host.disposeTerminal(pathOf(input, 'terminal'));
629
+ return { disposed: true };
630
+ },
631
+ },
632
+ /* Scheduled work, which is the `automations` group. */
633
+ {
634
+ name: 'list_automations',
635
+ group: 'automations',
636
+ title: 'List automations',
637
+ description: 'What the host runs on a schedule, whether each is on, and when it next fires.',
638
+ readOnly: true,
639
+ input: { type: 'object', properties: {}, additionalProperties: false },
640
+ run: async (host) => (await has(host.automations, 'automations')()).map((one) => ({
641
+ automation: one.resource,
642
+ title: one.title,
643
+ enabled: one.enabled,
644
+ ...(one.schedule === undefined ? {} : { schedule: one.schedule.expression, timeZone: one.schedule.timeZone }),
645
+ ...(one.nextRunAt === undefined ? {} : { nextRunAt: one.nextRunAt }),
646
+ operations: one.operations,
647
+ lastRuns: one.runs.slice(0, 5),
648
+ })),
649
+ },
650
+ {
651
+ name: 'run_automation',
652
+ group: 'automations',
653
+ title: 'Run an automation',
654
+ description: 'Fire an automation now, without waiting for its schedule. Answers once the host has taken it, not once it has finished.',
655
+ readOnly: false,
656
+ input: {
657
+ type: 'object',
658
+ properties: { automation: { type: 'string', description: 'An automation URI from list_automations.' } },
659
+ required: ['automation'],
660
+ additionalProperties: false,
661
+ },
662
+ run: async (host, input) => {
663
+ await has(host.runAutomation, 'automations')(pathOf(input, 'automation'));
664
+ return { started: true };
665
+ },
666
+ },
667
+ {
668
+ name: 'set_automation_enabled',
669
+ group: 'automations',
670
+ title: 'Turn an automation on or off',
671
+ description: 'Stop an automation firing, or start it again. The definition stays either way.',
672
+ readOnly: false,
673
+ input: {
674
+ type: 'object',
675
+ properties: { automation: { type: 'string' }, enabled: { type: 'boolean' } },
676
+ required: ['automation', 'enabled'],
677
+ additionalProperties: false,
678
+ },
679
+ run: async (host, input) => {
680
+ await has(host.setAutomationEnabled, 'automations')(pathOf(input, 'automation'), input.enabled === true);
681
+ return { enabled: input.enabled === true };
682
+ },
683
+ },
684
+ {
685
+ name: 'remove_automation',
686
+ group: 'automations',
687
+ title: 'Remove an automation',
688
+ description: 'Delete an automation from the host. Use set_automation_enabled to stop one without losing it.',
689
+ readOnly: false,
690
+ input: {
691
+ type: 'object',
692
+ properties: { automation: { type: 'string' } },
693
+ required: ['automation'],
694
+ additionalProperties: false,
695
+ },
696
+ run: async (host, input) => {
697
+ await has(host.removeAutomation, 'automations')(pathOf(input, 'automation'));
698
+ return { removed: true };
699
+ },
700
+ },
701
+ /* What a session changed, which is the `changes` group. */
702
+ {
703
+ name: 'list_changesets',
704
+ group: 'changes',
705
+ title: 'List changesets',
706
+ description: 'The changesets a session offers - what the conversation changed, what one turn changed, what the working tree has. Each is a URI show_changes takes.',
707
+ readOnly: true,
708
+ input: {
709
+ type: 'object',
710
+ properties: { session: { type: 'string' } },
711
+ required: ['session'],
712
+ additionalProperties: false,
713
+ },
714
+ run: async (host, input) => (await has(host.changesets, 'changesets')(uriOf(input))).map((scope) => ({
715
+ changeset: scope.uriTemplate,
716
+ label: scope.label,
717
+ ...(scope.description === undefined ? {} : { description: scope.description }),
718
+ // What is still to be filled in. A template with these left in it is not
719
+ // a URI yet, and saying so is better than the host refusing it later.
720
+ variables: scope.variables,
721
+ })),
722
+ },
723
+ {
724
+ name: 'show_changes',
725
+ group: 'changes',
726
+ title: 'Show a changeset',
727
+ description: 'The files in a changeset and how much each changed. The contents are not here: a changeset of two hundred files is a list worth having and megabytes that are not. Read one with read_file.',
728
+ readOnly: true,
729
+ input: {
730
+ type: 'object',
731
+ properties: {
732
+ session: { type: 'string' },
733
+ changeset: { type: 'string', description: 'A changeset URI from list_changesets. The session\'s own, if omitted.' },
734
+ },
735
+ required: ['session'],
736
+ additionalProperties: false,
737
+ },
738
+ run: async (host, input) => {
739
+ const target = text(input.changeset);
740
+ const found = await host.changes(uriOf(input), target === '' ? undefined : target);
741
+ return {
742
+ status: found.status,
743
+ files: found.files.map((file) => ({
744
+ uri: file.uri,
745
+ added: file.diff.added,
746
+ removed: file.diff.removed,
747
+ ...(file.before === undefined ? { created: true } : {}),
748
+ ...(file.after === undefined ? { deleted: true } : {}),
749
+ })),
750
+ operations: (found.operations ?? []).map((op) => op.id),
751
+ };
752
+ },
753
+ },
326
754
  ];
327
- /** One tool by the name a caller used, or nothing. */
755
+ /**
756
+ * One tool by the name a caller used, or nothing.
757
+ *
758
+ * Over the whole table, including groups nobody turned on - whether a tool
759
+ * exists and whether this server serves it are different questions, and
760
+ * `served` answers the second. Telling somebody the tool is in a group they
761
+ * did not ask for is a better answer than telling them it does not exist.
762
+ */
328
763
  export const named = (name) => TOOLS.find((one) => one.name === name);
764
+ /** The tools a server started with these groups serves. */
765
+ export const served = (groups = []) => TOOLS.filter((one) => one.group === undefined || groups.includes(one.group));
package/dist/src/tui.d.ts CHANGED
@@ -79,7 +79,7 @@ interface Options {
79
79
  publishWritable?: boolean;
80
80
  help: boolean;
81
81
  }
82
- export declare const USAGE = "ahpc - a terminal client for the Agent Host Protocol\n\n ahpc [options]\n\nThe host\n --host <url> A live agent host, ws://host:port\n --token <tkn> A bearer token for it\n --config-file <f> Read this instead of ~/.config/ahpc/config.json\n (none of these) The scripted host, which needs nothing installed\n\nWhere the agent works\n --path <dir> A path on the host, not on this machine. The host\n has to serve it, and says so if it does not.\n Left out, the host decides.\n\nWhat this client serves back\n --publish <dir> Serve this directory to the host under\n virtual://<clientId>/. Nothing is served without\n it, and every such request is refused.\n --publish-writable Let the host write into it. Read-only otherwise.\n Serving lasts as long as the screen does: with no\n terminal attached this prints one frame and exits,\n so a background shell publishes nothing.\n\nAppearance\n --theme <name> workbench, paper-light, ...\n --shell <name> The shell layout\n --screen <name> Which screen to open on\n --bood Let the creature loose on the whole screen. It\n keeps off the composer and off anything asking\n a question, and alt+g turns it off again.\n --session <uri> Open this session\n\nStills, for a README or a test\n --static, -s One frame to stdout instead of running\n --width, -w <n> Columns\n --height <n> Rows\n --unicode <level> ascii, bmp, full\n --colors <n> 0, 4, 8 or 24\n --svg <file> Write the still as SVG here\n --tick <ms> Milliseconds per scripted word\n --settled Run the script out before the frame\n --pump <n> Or exactly this many scripted words\n --say <text> Say this on the open session first\n --approve Answer the confirmation the script stops at\n --answer ...and then the question\n\n --help, -h This\n\nCommands\n ahpc <command> ... Drive a host without the screen. 'ahpc help' lists\n them: sessions, prompts, approvals, terminals.\n";
82
+ export declare const USAGE = "ahpc - a terminal client for the Agent Host Protocol\n\n ahpc [options]\n\nThe host\n --host <url> A live agent host, ws://host:port\n --token <tkn> A bearer token for it\n --config-file <f> Read this instead of ~/.config/ahpc/config.json\n (none of these) The scripted host, which needs nothing installed\n\nWhere the agent works\n --path <dir> A path on the host, not on this machine. The host\n has to serve it, and says so if it does not.\n Left out, the host decides.\n\nWhat this client serves back\n --publish <dir> Serve this directory to the host under\n virtual://<clientId>/. Nothing is served without\n it, and every such request is refused.\n --publish-writable Let the host write into it. Read-only otherwise.\n Serving lasts as long as the screen does: with no\n terminal attached this prints one frame and exits,\n so a background shell publishes nothing.\n\nAppearance\n --theme <name> workbench, paper-light, ...\n --shell <name> The shell layout\n --screen <name> Which screen to open on\n --bood Let the creature loose on the whole screen. It\n keeps off the composer and off anything asking\n a question, and alt+g turns it off again.\n --session <uri> Open this session\n\nStills, for a README or a test\n --static, -s One frame to stdout instead of running\n --width, -w <n> Columns\n --height <n> Rows\n --unicode <level> ascii, bmp, full\n --colors <n> 0, 4, 8 or 24\n --svg <file> Write the still as SVG here\n --tick <ms> Milliseconds per scripted word\n --settled Run the script out before the frame\n --pump <n> Or exactly this many scripted words\n --say <text> Say this on the open session first\n --approve Answer the confirmation the script stops at\n --answer ...and then the question\n\n --version, -v What version this is\n --help, -h This\n\nCommands\n ahpc <command> ... Drive a host without the screen. 'ahpc help' lists\n them: sessions, prompts, approvals, terminals.\n";
83
83
  export declare function parse(argv: string[]): Options;
84
84
  /**
85
85
  * The screen.
package/dist/src/tui.js CHANGED
@@ -53,6 +53,7 @@ Stills, for a README or a test
53
53
  --approve Answer the confirmation the script stops at
54
54
  --answer ...and then the question
55
55
 
56
+ --version, -v What version this is
56
57
  --help, -h This
57
58
 
58
59
  Commands
@@ -0,0 +1,2 @@
1
+ /** The version in the nearest `package.json`, or `unknown` where there is none. */
2
+ export declare const version: () => string;
@@ -0,0 +1,38 @@
1
+ /*
2
+ * What version this is, read from the manifest rather than written twice.
3
+ *
4
+ * A literal in the source is a literal that drifts: the one in `mcp/serve.ts`
5
+ * said 0.1 while the package said 0.2 within a day of being written, and a
6
+ * `--version` that lies is worse than no `--version` at all.
7
+ *
8
+ * Found by walking up from this module rather than by a fixed relative path,
9
+ * because the depth differs: `src/version.ts` in a checkout and
10
+ * `dist/src/version.js` in an install, and neither should have to know which
11
+ * it is. `package.json` is always at the package root and npm always ships
12
+ * it, so the first one above this file is the right one.
13
+ *
14
+ * This file imports nothing but Node, on purpose. The entry point answers
15
+ * `--version` before it decides which front end to load, and loading one to
16
+ * answer it would make the fastest question the slowest.
17
+ */
18
+ import { readFileSync } from 'node:fs';
19
+ import { dirname, join } from 'node:path';
20
+ import { fileURLToPath } from 'node:url';
21
+ /** The version in the nearest `package.json`, or `unknown` where there is none. */
22
+ export const version = () => {
23
+ let at = dirname(fileURLToPath(import.meta.url));
24
+ for (;;) {
25
+ try {
26
+ const found = JSON.parse(readFileSync(join(at, 'package.json'), 'utf8'));
27
+ if (typeof found.version === 'string')
28
+ return found.version;
29
+ }
30
+ catch { /* not this directory */ }
31
+ const up = dirname(at);
32
+ // The root of the filesystem, which means there is no manifest anywhere
33
+ // above this file - a bundler inlined it, or something unpacked it wrong.
34
+ if (up === at)
35
+ return 'unknown';
36
+ at = up;
37
+ }
38
+ };
@@ -24,6 +24,17 @@ export interface TurnOptions {
24
24
  id: string;
25
25
  name: string;
26
26
  }): void;
27
+ /**
28
+ * A tool the agent has started using, said once per call.
29
+ *
30
+ * Every tool call rather than only the ones that stop for a person, because
31
+ * this is what a caller watching a long turn has to go on: `onWaiting` fires
32
+ * on an approval and most turns never ask for one.
33
+ */
34
+ onStep?(call: {
35
+ id: string;
36
+ name: string;
37
+ }): void;
27
38
  }
28
39
  /**
29
40
  * Say something, and block until the turn it starts has finished.
package/dist/src/wait.js CHANGED
@@ -79,6 +79,14 @@ export async function turn(host, uri, text, options = {}) {
79
79
  let first = true;
80
80
  let noted;
81
81
  let answer;
82
+ /** Tool calls already reported, so a snapshot rebuilt per token says each once. */
83
+ const stepped = new Set();
84
+ const step = (call) => {
85
+ if (stepped.has(call.id))
86
+ return;
87
+ stepped.add(call.id);
88
+ options.onStep?.(call);
89
+ };
82
90
  const finished = until(host, uri, (event) => {
83
91
  /*
84
92
  * Two vocabularies, because a host may speak either.
@@ -98,8 +106,9 @@ export async function turn(host, uri, text, options = {}) {
98
106
  options.onDelta?.(event.text);
99
107
  return false;
100
108
  }
101
- if (event.type === 'toolCall' && event.call.status === 'pending-confirmation') {
102
- if (noted !== event.call.id) {
109
+ if (event.type === 'toolCall') {
110
+ step({ id: event.call.id, name: event.call.name });
111
+ if (event.call.status === 'pending-confirmation' && noted !== event.call.id) {
103
112
  noted = event.call.id;
104
113
  options.onWaiting?.({ id: event.call.id, name: event.call.name });
105
114
  }
@@ -124,6 +133,10 @@ export async function turn(host, uri, text, options = {}) {
124
133
  options.onDelta?.(now.slice(given));
125
134
  given = now.length;
126
135
  }
136
+ for (const part of event.active.parts) {
137
+ if (part.kind === 'toolCall')
138
+ step({ id: part.call.id, name: part.call.name });
139
+ }
127
140
  const call = event.active.parts.find((part) => part.kind === 'toolCall'
128
141
  && part.call.status === 'pending-confirmation');
129
142
  if (call?.kind === 'toolCall' && noted !== call.call.id) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@softov/ahpc",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "type": "module",
5
5
  "description": "A terminal client for the Agent Host Protocol: sessions, a streaming transcript, and interactive prompts",
6
6
  "keywords": [