@aion0/forge 0.10.20 → 0.10.23

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.
@@ -26,6 +26,7 @@ import {
26
26
  import { getMemoryStore } from './memory-store';
27
27
  import { buildMemoryContext } from './build-memory-context';
28
28
  import { buildMemoryTools } from './memory-tools';
29
+ import { buildStartWatchTool } from '../watch/start-watch-tool';
29
30
  import { estimateTokens } from '../memory/token-estimate';
30
31
  import {
31
32
  listInstalledConnectors,
@@ -48,10 +49,25 @@ const MAX_TOKENS = 16000;
48
49
  // and recalled via buildMemoryContext as compact blocks instead.
49
50
  const HISTORY_MSG_BUDGET = 60;
50
51
  const HISTORY_TOKEN_BUDGET = 8000;
52
+ // Hard cap on a single tool_result stored into the conversation (chars).
53
+ // A giant result (e.g. a connector returning a full test tree) would
54
+ // otherwise blow the whole HISTORY_TOKEN_BUDGET, push its paired
55
+ // assistant tool_use out of the window, and leave an orphan tool_result
56
+ // that trimOrphanToolResults strips — yielding an empty history and an
57
+ // "messages must not be empty" provider error. ~16k chars ≈ 4k tokens,
58
+ // half the budget, so a complete tool_use+result pair always survives.
59
+ const MAX_TOOL_RESULT_CHARS = 16000;
51
60
 
52
61
  // After clipping to last N, the first kept message may be a tool_result
53
62
  // whose tool_use was cut. Anthropic/OpenAI both reject that, so drop
54
63
  // leading tool_result-bearing user messages until the slice starts clean.
64
+ function truncateToolResult(s: string): string {
65
+ if (s.length <= MAX_TOOL_RESULT_CHARS) return s;
66
+ return s.slice(0, MAX_TOOL_RESULT_CHARS) +
67
+ `\n\n[… tool result truncated: ${s.length} chars total, showing first ${MAX_TOOL_RESULT_CHARS}. ` +
68
+ `Refine the call (filter / paginate / flatten) to get a smaller, complete result.]`;
69
+ }
70
+
55
71
  function trimOrphanToolResults(history: Message[]): Message[] {
56
72
  let i = 0;
57
73
  while (i < history.length) {
@@ -73,6 +89,7 @@ export interface AgentEvent {
73
89
  | 'message_saved' // a full message persisted (assistant or tool-results carrier)
74
90
  | 'memory_status' // pinned/blocks/hits snapshot from Temper for the UI strip
75
91
  | 'turn_done' // loop finished
92
+ | 'watch_status' // ambient background-watch progress (status chip, NOT a message)
76
93
  | 'error'; // unrecoverable
77
94
  message_id?: string;
78
95
  data?: any;
@@ -308,9 +325,9 @@ function buildConnectorTools(): LlmTool[] {
308
325
  for (const entry of getConnectorEntries(def)) {
309
326
  for (const [toolName, tool] of Object.entries(entry.tools || {})) {
310
327
  // Executable if it has a script (browser protocol) OR a non-browser
311
- // protocol that runs server-side (http / shell).
328
+ // protocol that runs server-side (http / shell / ssh).
312
329
  const protocol = (tool as any).protocol;
313
- const isServerSide = protocol === 'http' || protocol === 'shell';
330
+ const isServerSide = protocol === 'http' || protocol === 'shell' || protocol === 'ssh';
314
331
  if (!tool.script && !isServerSide) continue;
315
332
  const properties: Record<string, unknown> = {};
316
333
  const required: string[] = [];
@@ -392,6 +409,11 @@ export async function runTurn(args: RunTurnArgs): Promise<{ ok: boolean; error?:
392
409
  const memHandlers: Record<string, BuiltinHandler> = {};
393
410
  for (const t of memTools) memHandlers[t.def.name] = t.handle;
394
411
 
412
+ // start_watch — LLM-driven background watch (always available). Bound
413
+ // to this session so completion reports back here.
414
+ const watchTool = buildStartWatchTool(args.sessionId);
415
+ memHandlers[watchTool.def.name] = watchTool.handle;
416
+
395
417
  if (memStore.enabled) {
396
418
  // Inspector strip (memory_status event) wants the full inventory —
397
419
  // keep its own listBlocks call. The prompt-injection text comes
@@ -466,6 +488,7 @@ export async function runTurn(args: RunTurnArgs): Promise<{ ok: boolean; error?:
466
488
  const builtinDefsAll = [
467
489
  ...BUILTIN_TOOL_DEFS,
468
490
  ...memTools.map((m) => m.def),
491
+ watchTool.def,
469
492
  ];
470
493
  const allTools: LlmTool[] = [
471
494
  ...builtinDefsAll.map((t) => ({
@@ -500,6 +523,13 @@ export async function runTurn(args: RunTurnArgs): Promise<{ ok: boolean; error?:
500
523
  const history = trimOrphanToolResults(
501
524
  listMessagesCapped(args.sessionId, HISTORY_MSG_BUDGET, HISTORY_TOKEN_BUDGET, estimateTokens),
502
525
  );
526
+ // Belt-and-suspenders: tool_result truncation should keep a complete
527
+ // pair in-window, but if history is somehow empty, fail clearly
528
+ // instead of letting the provider throw "messages must not be empty".
529
+ if (history.length === 0) {
530
+ cb({ type: 'error', data: { error: 'Conversation context is empty after trimming an oversized result. Clear the chat or retry with a narrower query.' } });
531
+ return { ok: false, error: 'empty history' };
532
+ }
503
533
 
504
534
  assistantBlocksAccum = [];
505
535
  let currentTextBuf = '';
@@ -543,11 +573,11 @@ export async function runTurn(args: RunTurnArgs): Promise<{ ok: boolean; error?:
543
573
  const toolUses = result.content.filter((b): b is ToolUseBlock => b.type === 'tool_use');
544
574
  const toolResults: ToolResultBlock[] = [];
545
575
  for (const t of toolUses) {
546
- const r = await dispatchTool({ id: t.id, name: t.name, input: t.input }, memHandlers);
576
+ const r = await dispatchTool({ id: t.id, name: t.name, input: t.input }, { extraBuiltins: memHandlers, sessionId: args.sessionId });
547
577
  const block: ToolResultBlock = {
548
578
  type: 'tool_result',
549
579
  tool_use_id: t.id,
550
- content: r.content,
580
+ content: truncateToolResult(r.content),
551
581
  is_error: r.is_error,
552
582
  };
553
583
  toolResults.push(block);
@@ -13,13 +13,13 @@ const BRIDGE_PORT = Number(process.env.BRIDGE_PORT) || 8407;
13
13
  interface BridgeRpcOk { ok: true; value: unknown }
14
14
  interface BridgeRpcErr { ok: false; error: string }
15
15
 
16
- export async function bridgeRpc(method: string, params: unknown): Promise<unknown> {
16
+ export async function bridgeRpc(method: string, params: unknown, timeoutMs?: number): Promise<unknown> {
17
17
  let res: Response;
18
18
  try {
19
19
  res = await fetch(`http://127.0.0.1:${BRIDGE_PORT}/api/rpc`, {
20
20
  method: 'POST',
21
21
  headers: { 'content-type': 'application/json' },
22
- body: JSON.stringify({ method, params }),
22
+ body: JSON.stringify({ method, params, ...(timeoutMs ? { timeout_ms: timeoutMs } : {}) }),
23
23
  });
24
24
  } catch (e) {
25
25
  throw new Error(`browser bridge unreachable on port ${BRIDGE_PORT}: ${(e as Error).message}`);
@@ -0,0 +1,206 @@
1
+ /**
2
+ * SSH protocol runtime for connector tools (`protocol: ssh`).
3
+ *
4
+ * Drives the system `ssh` binary through a PTY (node-pty) so it can
5
+ * handle interactive flows the plain `shell` protocol can't: password
6
+ * auth and mid-command confirmations like `(y/N)`. Built for network
7
+ * devices — e.g. FortiNAC `execute restore image scp …` which prompts
8
+ * twice for `y` then streams a multi-minute restore before rebooting.
9
+ *
10
+ * Declarative, expect-style: the manifest's `ssh` block says what to
11
+ * send, what to auto-answer, the success/failure markers, and which
12
+ * regexes to capture from the transcript. Nothing here is FortiNAC-
13
+ * specific.
14
+ *
15
+ * Safety: connectors are user-installed. An ssh-protocol tool can run
16
+ * arbitrary remote commands — review at install time. The password is
17
+ * fed silently (ssh doesn't echo it) so it never lands in the captured
18
+ * transcript; we also never log it.
19
+ */
20
+
21
+ import type { ConnectorTool, SshSpec } from '../../connectors/types';
22
+ import { expandAllTokens } from '../../plugins/templates';
23
+ import * as pty from 'node-pty';
24
+
25
+ export interface SshProtocolArgs {
26
+ tool: ConnectorTool;
27
+ settings: Record<string, any>;
28
+ args: Record<string, any>;
29
+ }
30
+
31
+ export interface SshProtocolResult {
32
+ content: string;
33
+ is_error?: boolean;
34
+ }
35
+
36
+ const DEFAULT_TIMEOUT_MS = 120_000;
37
+ const MAX_TIMEOUT_MS = 280_000;
38
+ const MAX_OUTPUT_BYTES = 24 * 1024;
39
+
40
+ function truncate(s: string): string {
41
+ const buf = Buffer.from(s, 'utf-8');
42
+ if (buf.byteLength <= MAX_OUTPUT_BYTES) return s;
43
+ // Keep the tail — the interesting markers (done/reboot) are at the end.
44
+ return `(…truncated, total ${buf.byteLength} bytes)\n` +
45
+ buf.subarray(buf.byteLength - MAX_OUTPUT_BYTES).toString('utf-8');
46
+ }
47
+
48
+ function rx(pattern: string | undefined): RegExp | null {
49
+ if (!pattern) return null;
50
+ try { return new RegExp(pattern, 'i'); } catch { return null; }
51
+ }
52
+
53
+ /**
54
+ * Resolve what to actually type for an auto-answer. If the rule's value is
55
+ * the intent `yes`/`no`, pick the token the prompt itself offers — `(yes/no)`
56
+ * → `yes`/`no`, otherwise `y`/`n`. We always send an EXPLICIT token (never
57
+ * rely on the prompt's default: `(y/N)` defaults to N, so "continue" must
58
+ * send `y` outright). Any other value is sent literally.
59
+ */
60
+ function resolveAnswer(send: string, promptChunk: string): string {
61
+ const intent = String(send || '').trim().toLowerCase();
62
+ if (intent !== 'yes' && intent !== 'no') return send; // literal passthrough
63
+ const offersWords = /\byes\s*\/\s*no\b/i.test(promptChunk);
64
+ if (intent === 'yes') return offersWords ? 'yes' : 'y';
65
+ return offersWords ? 'no' : 'n';
66
+ }
67
+
68
+ export async function runSsh({ tool, settings, args }: SshProtocolArgs): Promise<SshProtocolResult> {
69
+ const specRaw = tool.ssh;
70
+ if (!specRaw) return { content: 'ssh tool missing `ssh` block', is_error: true };
71
+
72
+ const exp = (s: string | undefined) => (s == null ? '' : expandAllTokens(String(s), settings, args));
73
+
74
+ const spec: SshSpec = specRaw;
75
+
76
+ // Resolve connection params: chat arg > connector setting > literal in
77
+ // the ssh block > built-in default. (IP comes from chat; port/user/
78
+ // password fall back to the connector's saved defaults.)
79
+ const pickConn = (
80
+ argKeys: string[], settingKey: string, specVal: unknown, dflt: string, secret: boolean,
81
+ ): string => {
82
+ for (const k of argKeys) {
83
+ const v = args?.[k];
84
+ if (v != null && String(v) !== '') return secret ? String(v) : String(v).trim();
85
+ }
86
+ const sv = settings?.[settingKey];
87
+ if (sv != null && String(sv) !== '') return secret ? String(sv) : String(sv).trim();
88
+ if (specVal != null && specVal !== '') {
89
+ const r = exp(String(specVal));
90
+ if (r && !r.includes('{')) return secret ? r : r.trim(); // skip unresolved templates
91
+ }
92
+ return dflt;
93
+ };
94
+ const host = pickConn(['host'], 'host', spec.host, '', false);
95
+ const port = pickConn(['port'], 'port', spec.port, '22', false);
96
+ const user = pickConn(['username', 'user'], 'username', spec.user, '', false);
97
+ const password = pickConn(['password'], 'password', spec.password, '', true);
98
+ if (!host) return { content: 'ssh: host is required (pass it from chat, e.g. host=10.15.52.152)', is_error: true };
99
+ if (!user) return { content: 'ssh: user is required (pass username, or set a connector default)', is_error: true };
100
+
101
+ const commands = (spec.commands || []).map((c) => exp(c));
102
+ const autoAnswer = (spec.auto_answer || []).map((r) => ({ re: rx(r.match), send: exp(r.send) }));
103
+ const promptRe = rx(spec.prompt_regex) || /[#$>]\s*$/;
104
+ const doneRe = rx(spec.done_when);
105
+ const failRe = rx(spec.fail_when);
106
+ const passwordRe = /password:\s*$/i;
107
+ const timeoutMs = Math.min(MAX_TIMEOUT_MS, Math.max(2_000, Number(spec.timeout_sec || 0) * 1000 || DEFAULT_TIMEOUT_MS));
108
+
109
+ const sshArgs = [
110
+ '-tt', // force PTY for interactive prompts
111
+ '-p', port,
112
+ '-o', 'StrictHostKeyChecking=accept-new', // no yes/no host-key prompt
113
+ '-o', 'UserKnownHostsFile=/dev/null', // don't pollute known_hosts
114
+ '-o', 'GlobalKnownHostsFile=/dev/null',
115
+ '-o', 'ConnectTimeout=15',
116
+ '-o', 'NumberOfPasswordPrompts=2',
117
+ `${user}@${host}`,
118
+ ];
119
+
120
+ return new Promise<SshProtocolResult>((resolve) => {
121
+ let term: pty.IPty;
122
+ try {
123
+ term = pty.spawn('ssh', sshArgs, {
124
+ name: 'xterm-color',
125
+ cols: 200, rows: 50,
126
+ cwd: process.env.HOME || process.cwd(),
127
+ env: process.env as Record<string, string>,
128
+ });
129
+ } catch (e) {
130
+ return resolve({ content: `ssh spawn failed: ${(e as Error).message}`, is_error: true });
131
+ }
132
+
133
+ let full = '';
134
+ let cmdIndex = 0;
135
+ let pwSent = 0;
136
+ let settled = false;
137
+ const captured: Record<string, string> = {};
138
+
139
+ const finish = (is_error: boolean, note: string) => {
140
+ if (settled) return;
141
+ settled = true;
142
+ clearTimeout(timer);
143
+ try { term.kill(); } catch {}
144
+ // Run captures over the full transcript.
145
+ if (spec.capture) {
146
+ for (const [name, pat] of Object.entries(spec.capture)) {
147
+ const m = full.match(rx(pat) || /$^/);
148
+ if (m && m[1] != null) captured[name] = m[1];
149
+ }
150
+ }
151
+ const payload = {
152
+ ok: !is_error,
153
+ note,
154
+ ...(Object.keys(captured).length ? { captured } : {}),
155
+ output_tail: truncate(full).slice(-4000),
156
+ };
157
+ resolve({ content: JSON.stringify(payload), is_error });
158
+ };
159
+
160
+ const timer = setTimeout(() => finish(true, `timed out after ${timeoutMs / 1000}s`), timeoutMs);
161
+
162
+ term.onData((chunk: string) => {
163
+ full += chunk;
164
+ // 1) success / failure markers (check on a trailing window so a
165
+ // marker split across chunks still matches).
166
+ const tail = full.slice(-2000);
167
+ if (doneRe && doneRe.test(tail)) return finish(false, 'done marker matched');
168
+ if (failRe && failRe.test(tail)) return finish(true, 'failure marker matched');
169
+
170
+ // 2) password prompt → feed password silently.
171
+ if (password && passwordRe.test(chunk)) {
172
+ if (pwSent >= 2) return finish(true, 'authentication failed (password rejected)');
173
+ pwSent++;
174
+ term.write(`${password}\r`);
175
+ return;
176
+ }
177
+
178
+ // 3) interactive confirmations — resolve the correct token (y/yes/
179
+ // n/no) from THIS prompt's offered options (intent `yes`/`no`).
180
+ for (const rule of autoAnswer) {
181
+ if (rule.re && rule.re.test(chunk)) {
182
+ term.write(`${resolveAnswer(rule.send, chunk)}\r`);
183
+ return;
184
+ }
185
+ }
186
+
187
+ // 4) shell prompt → send the next queued command.
188
+ if (promptRe.test(chunk) && cmdIndex < commands.length) {
189
+ const next = commands[cmdIndex++];
190
+ term.write(`${next}\r`);
191
+ return;
192
+ }
193
+ });
194
+
195
+ term.onExit(({ exitCode }) => {
196
+ if (settled) return;
197
+ // Connection closed. Success only if explicitly allowed, or a done
198
+ // marker already landed (covered above). Otherwise treat as error.
199
+ if (spec.success_on_close && cmdIndex >= commands.length) {
200
+ return finish(false, `connection closed (exit ${exitCode})`);
201
+ }
202
+ const sawDone = doneRe ? doneRe.test(full) : false;
203
+ finish(!sawDone, sawDone ? 'done before close' : `connection closed unexpectedly (exit ${exitCode})`);
204
+ });
205
+ });
206
+ }
@@ -12,6 +12,7 @@
12
12
  import { bridgeRpc } from './bridge-client';
13
13
  import { runHttp } from './protocols/http';
14
14
  import { runShell } from './protocols/shell';
15
+ import { runSsh } from './protocols/ssh';
15
16
  import {
16
17
  getConnector,
17
18
  getInstalledConnector,
@@ -482,6 +483,12 @@ export interface DispatchOptions {
482
483
  * therefore don't need an LLM-friendly truncation.
483
484
  */
484
485
  noTruncation?: boolean;
486
+ /** Chat session that triggered this call — used to register a watch
487
+ * (async tools) bound to the right session for completion callbacks. */
488
+ sessionId?: string;
489
+ /** Remaining chain budget for async watch callbacks. Defaults to the
490
+ * full depth at the top level; decremented when a watch chains a tool. */
491
+ chainDepth?: number;
485
492
  }
486
493
 
487
494
  export async function dispatchTool(
@@ -522,6 +529,18 @@ export async function dispatchTool(
522
529
  const protocol = located.tool.protocol || 'browser';
523
530
  const argInput = (call.input ?? {}) as Record<string, any>;
524
531
 
532
+ // Apply each parameter's `default` for keys the model omitted, so
533
+ // template tokens like {args.scp_host} resolve instead of staying
534
+ // literal. JSON-schema defaults are only advisory to the model — it
535
+ // routinely drops optional fields — so fill them here. Only sets
536
+ // missing/null; never overrides a value the model actually passed.
537
+ for (const [pname, pdef] of Object.entries(located.tool.parameters || {})) {
538
+ if (pdef && typeof pdef === 'object' && 'default' in (pdef as any)
539
+ && (argInput[pname] === undefined || argInput[pname] === null)) {
540
+ argInput[pname] = (pdef as any).default;
541
+ }
542
+ }
543
+
525
544
  // Multi-instance overlay: when a connector's settings carry a
526
545
  // `instances` array of `{name, ...}` objects, the tool's `instance`
527
546
  // arg picks one and its fields are merged into the top-level settings
@@ -554,28 +573,64 @@ export async function dispatchTool(
554
573
  }
555
574
 
556
575
  try {
576
+ let result: ToolResult;
557
577
  switch (protocol) {
558
578
  case 'http':
559
- return await runHttp({ tool: located.tool, settings: effectiveSettings, args: argInput, connectorAuth: def.auth, noTruncation: opts.noTruncation });
579
+ result = await runHttp({ tool: located.tool, settings: effectiveSettings, args: argInput, connectorAuth: def.auth, noTruncation: opts.noTruncation });
580
+ break;
560
581
  case 'shell':
561
- return await runShell({ tool: located.tool, settings: effectiveSettings, args: argInput });
582
+ result = await runShell({ tool: located.tool, settings: effectiveSettings, args: argInput });
583
+ break;
584
+ case 'ssh':
585
+ result = await runSsh({ tool: located.tool, settings: effectiveSettings, args: argInput });
586
+ break;
562
587
  case 'browser': {
563
588
  // Hand the whole connector + tool spec + input + settings to the
564
589
  // extension's runner.ts via the bridge. The extension keeps owning
565
590
  // the runner logic (tab acquire, navigate, executeScript).
566
591
  const connector = buildConnectorPayload(def, located.entry, effectiveSettings);
567
- const result = (await bridgeRpc('connector.run', {
592
+ const r = (await bridgeRpc('connector.run', {
568
593
  pluginId: located.connectorId, // wire-name kept for extension
569
594
  toolName: located.toolName,
570
595
  input: argInput,
571
596
  connector,
572
597
  settings: effectiveSettings,
573
- })) as { content?: string; is_error?: boolean } | null;
574
- return { content: result?.content ?? '(no content returned)', is_error: !!result?.is_error };
598
+ }, located.tool.timeout_ms)) as { content?: string; is_error?: boolean } | null;
599
+ result = { content: r?.content ?? '(no content returned)', is_error: !!r?.is_error };
600
+ break;
575
601
  }
576
602
  default:
577
603
  return { content: `unknown protocol "${protocol}" on tool ${call.name}`, is_error: true };
578
604
  }
605
+
606
+ // Async (long-task watch): if the tool declared an `async` block and
607
+ // it ran without error, register a background watch that polls to
608
+ // completion and reports back to the originating chat session. The
609
+ // tool's own result is returned to the caller immediately (detach).
610
+ if (located.tool.async && !result.is_error) {
611
+ try {
612
+ const { registerWatch, DEFAULT_CHAIN_DEPTH } = await import('../watch/register');
613
+ let parsed: unknown = result.content;
614
+ try { parsed = JSON.parse(result.content); } catch { /* keep string */ }
615
+ const reg = registerWatch({
616
+ spec: located.tool.async,
617
+ connectorId: located.connectorId,
618
+ toolName: located.toolName,
619
+ args: argInput,
620
+ result: parsed,
621
+ settings: effectiveSettings,
622
+ sessionId: opts.sessionId ?? null,
623
+ chainDepth: opts.chainDepth ?? DEFAULT_CHAIN_DEPTH,
624
+ });
625
+ const note = reg.ok
626
+ ? `\n\n[watch ${reg.watch_id} registered — polling in the background; you'll get a chat update on completion.]`
627
+ : `\n\n[watch not registered: ${reg.reason}]`;
628
+ result = { ...result, content: result.content + note };
629
+ } catch (e) {
630
+ console.warn('[dispatch] registerWatch failed', (e as Error).message);
631
+ }
632
+ }
633
+ return result;
579
634
  } catch (e) {
580
635
  return { content: `connector tool failed: ${(e as Error).message}`, is_error: true };
581
636
  }
@@ -37,6 +37,7 @@ import {
37
37
  } from './chat/session-store';
38
38
  import { runTurn, type AgentEvent } from './chat/agent-loop';
39
39
  import { bridgePush } from './chat/bridge-client';
40
+ import { startWatchRunner } from './watch/watch-runner';
40
41
 
41
42
  const PORT = Number(process.env.CHAT_PORT) || 8408;
42
43
  const startTime = Date.now();
@@ -302,6 +303,17 @@ httpServer.listen(PORT, '127.0.0.1', () => {
302
303
  const main = ensureMainSession();
303
304
  console.log(`[chat] Main session: ${main.id.slice(0, 8)} "${main.title}"`);
304
305
  } catch (e) { console.warn('[chat] ensureMainSession failed:', (e as Error).message); }
306
+
307
+ // Background long-task watches: poll to completion, then feed the
308
+ // result back into the originating session (assistant replies) or push
309
+ // ambient progress (status chip, not a message).
310
+ startWatchRunner({
311
+ onProgress: (sessionId, payload) => fanoutEvent(sessionId, { type: 'watch_status', data: payload }),
312
+ runChat: (sessionId, text) => {
313
+ void runTurn({ sessionId, userText: text, callbacks: { onEvent: (e) => fanoutEvent(sessionId, e) } })
314
+ .catch((err) => console.error('[watch] runChat failed', (err as Error).message));
315
+ },
316
+ });
305
317
  });
306
318
 
307
319
  function shutdown(): void {
@@ -14,7 +14,101 @@
14
14
  export type ConnectorRunner = 'main' | 'isolated';
15
15
 
16
16
  /** Where a tool's execution lives. */
17
- export type ConnectorProtocol = 'browser' | 'http' | 'shell';
17
+ export type ConnectorProtocol = 'browser' | 'http' | 'shell' | 'ssh';
18
+
19
+ /** One expect rule for `protocol: ssh`: when output matches `match`
20
+ * (a regex, tested per output chunk), send `send` + Enter. Used to
21
+ * auto-answer interactive prompts like `(y/N)`.
22
+ *
23
+ * `send` may be the INTENT `yes`/`no` — the runner then picks the token
24
+ * the prompt actually offers (`(y/N)` → `y`/`n`, `(yes/no)` → `yes`/`no`)
25
+ * and always sends it explicitly (never relies on the prompt's default).
26
+ * Any other value is sent literally. */
27
+ export interface SshExpectRule {
28
+ match: string;
29
+ send: string;
30
+ }
31
+
32
+ /** One completion/failure action for an async watch. */
33
+ export interface WatchAction {
34
+ /** chat = feed result back to the originating session (assistant replies);
35
+ * tool = dispatch a tool (chaining); none = just record terminal state. */
36
+ mode?: 'chat' | 'tool' | 'none';
37
+ /** mode=chat: text injected into the session (templated with {poll.*}). */
38
+ message?: string;
39
+ /** mode=tool: `<connector>.<tool>` to dispatch. */
40
+ tool?: string;
41
+ /** mode=tool: args (templated with {poll.*}/{args.*}). */
42
+ args?: Record<string, unknown>;
43
+ }
44
+
45
+ /**
46
+ * `async` block — declares a tool as a long-running task that Forge
47
+ * watches in the background. Templating in poll_args / done_match.equals /
48
+ * action args / progress.message: {args.*} = the trigger's input,
49
+ * {result.*} = the trigger's return value (resolved once at register
50
+ * time), {poll.*} = the latest poll result (resolved per use).
51
+ */
52
+ export interface AsyncWatchSpec {
53
+ /** Tool to poll (same connector), bare tool name e.g. `get_version`. */
54
+ poll: string;
55
+ /** Args for each poll call (templated against trigger args/result). */
56
+ poll_args?: Record<string, unknown>;
57
+ /** (A) poll-result path that, when truthy, means done. */
58
+ done_path?: string;
59
+ /** (B) value comparison on a poll-result path. */
60
+ done_match?: { path: string; equals?: string; contains?: string };
61
+ /** poll-result path that, when truthy, means failed. */
62
+ fail_path?: string;
63
+ /** Seconds between polls (default 60, min 30). */
64
+ interval_sec?: number;
65
+ /** Overall deadline in seconds (default 1200). */
66
+ timeout_sec?: number;
67
+ /** Hard cap on poll count (default 40). */
68
+ max_polls?: number;
69
+ /** Completion action (default {mode:'chat'}). */
70
+ on_done?: WatchAction;
71
+ /** Failure/timeout action (default {mode:'chat'}). */
72
+ on_fail?: WatchAction;
73
+ /** Per-poll ambient progress (does not enter the message thread). */
74
+ progress?: { show?: boolean; message?: string };
75
+ }
76
+
77
+ /**
78
+ * `protocol: ssh` spec — drives an interactive SSH session via a PTY
79
+ * (the system `ssh` binary). Built for devices whose CLI needs a
80
+ * password + interactive confirmations (e.g. FortiNAC firmware restore).
81
+ * All string fields are templated with {settings.*}/{args.*}.
82
+ */
83
+ export interface SshSpec {
84
+ // Connection params are resolved by the runner with this precedence:
85
+ // tool arg (host/port/username/password) > connector setting
86
+ // (host/port/username/password) > the literal here > built-in default.
87
+ // So chat can pass them per-call and the connector holds defaults; the
88
+ // IP typically comes from chat only (no setting). All optional here.
89
+ host?: string;
90
+ /** Default 22. */
91
+ port?: string | number;
92
+ user?: string;
93
+ /** Password fed when a `password:` prompt appears (sent silently). */
94
+ password?: string;
95
+ /** Commands sent one-per-shell-prompt, in order (e.g. the upgrade cmd, then exit). */
96
+ commands?: string[];
97
+ /** Auto-answers applied throughout the session (e.g. `(y/N)` → `y`). */
98
+ auto_answer?: SshExpectRule[];
99
+ /** Shell-prompt regex that gates sending the next command. Default `[#$>]\s*$`. */
100
+ prompt_regex?: string;
101
+ /** Success marker regex — when seen, the session resolves ok and ssh is closed. */
102
+ done_when?: string;
103
+ /** Failure marker regex — when seen, resolves is_error. */
104
+ fail_when?: string;
105
+ /** name → regex(1 capture group) pulled from the full transcript into the result. */
106
+ capture?: Record<string, string>;
107
+ /** Overall timeout. Default 120s, max 280s. */
108
+ timeout_sec?: number;
109
+ /** Treat the remote closing the connection as success (e.g. after `exit`). */
110
+ success_on_close?: boolean;
111
+ }
18
112
 
19
113
  /** Schema for one settings or parameter field. */
20
114
  export interface ConnectorFieldSchema {
@@ -170,7 +264,29 @@ export interface ConnectorTool {
170
264
  /** Extra env vars (values templated). */
171
265
  env?: Record<string, string>;
172
266
 
173
- /** shell/http: timeout in milliseconds. Default 30000, max 300000. */
267
+ // ── protocol: 'ssh' ───────────────────────────────────────
268
+ /** Interactive SSH session spec (PTY-driven). See SshSpec. */
269
+ ssh?: SshSpec;
270
+
271
+ // ── async (long-task watch) ───────────────────────────────
272
+ /**
273
+ * Marks this tool as a long-running task. After it runs (and detaches
274
+ * quickly), Forge registers a background **watch** that periodically
275
+ * polls `async.poll` until done/failed/timeout, then feeds the result
276
+ * back into the originating chat session (or chains a tool). See
277
+ * AsyncWatchSpec + lib/watch/. The lightweight async-callback primitive
278
+ * for chat-driven background tasks (NAC upgrade, pytest runs, …).
279
+ */
280
+ async?: AsyncWatchSpec;
281
+
282
+ /**
283
+ * Timeout in milliseconds.
284
+ * - shell/http: request timeout (default 30000, max 300000).
285
+ * - browser: how long the bridge waits for the extension to return the
286
+ * RPC result (default 60000, capped at 900000). Raise it for tools
287
+ * whose script issues a long synchronous backend call (e.g. a NAC
288
+ * upgrade that blocks for minutes).
289
+ */
174
290
  timeout_ms?: number;
175
291
 
176
292
  /**
@@ -451,3 +451,45 @@ When the user reports a bug ("the list_my_issues tool returned 0 rows"):
451
451
  `script` body needs to change. Bump version, save, retry — the
452
452
  registry-based update path is for connectors that came from a
453
453
  shared `forge-connectors` repo.
454
+
455
+ ## Long-running tools — the `async` block (background watch)
456
+
457
+ A tool that kicks off work which finishes minutes later (a firmware
458
+ upgrade, a test run) should NOT hold the chat open. Declare an `async`
459
+ block: the tool runs and returns immediately (detach), and Forge
460
+ registers a background **watch** that polls until done, then reports
461
+ back into the originating chat session — no AI babysitting.
462
+
463
+ ```yaml
464
+ upgrade:
465
+ protocol: ssh
466
+ async:
467
+ poll: get_version # another tool in THIS connector to poll
468
+ poll_args: # built once from the trigger's args/result
469
+ host: "{args.host}" # {args.*}=trigger input, {result.*}=trigger return
470
+ # completion test — one of:
471
+ done_path: done # (a) poll-result path is truthy
472
+ done_match: # (b) value compare
473
+ path: captured.build
474
+ equals: "{result.captured.target_build}"
475
+ fail_path: error # optional: truthy = failed
476
+ interval_sec: 60 # poll cadence (min 30)
477
+ timeout_sec: 900 # overall deadline
478
+ max_polls: 15 # hard cap
479
+ on_done: { mode: chat, message: "Done — build {poll.captured.build}." }
480
+ on_fail: { mode: chat, message: "Not confirmed — check manually." }
481
+ progress: { show: true, message: "Working… {poll_count}/{max_polls}" }
482
+ ```
483
+
484
+ - `on_done`/`on_fail.mode`: `chat` (default — assistant replies in the
485
+ session; a telegram-origin session replies on telegram), `tool`
486
+ (chain another tool — `tool` + `args`, depth-limited), or `none`.
487
+ - `progress` shows an ambient status chip per poll — it does NOT enter
488
+ the message thread or trigger the LLM.
489
+ - Templating: `{poll.*}` = latest poll result; `{poll_count}`/`{max_polls}`.
490
+ - Guards (not overridable to unbounded): max_polls, timeout,
491
+ max_lifetime, consecutive-error cutoff, chain depth, global active cap.
492
+ - Watches persist in SQLite (survive restart) and are listed/cancelable
493
+ in /chat's "Background watches" panel.
494
+ - Secrets: don't put a password in `poll_args` (it persists in the watch
495
+ row). Rely on the connector's saved default credential instead.