@aion0/forge 0.10.18 → 0.10.22

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.
@@ -48,6 +48,11 @@ import { randomUUID, createHash } from 'node:crypto';
48
48
  const PORT = Number(process.env.BRIDGE_PORT) || 8407;
49
49
  const FORGE_PORT = Number(process.env.PORT) || 8403;
50
50
  const RPC_TIMEOUT_MS = 60_000;
51
+ // Ceiling for per-call overrides. Kept just under undici's default 300s
52
+ // headersTimeout on the loopback fetch in bridge-client.ts — past that the
53
+ // client fetch dies first with an opaque error. Genuinely long backend work
54
+ // (multi-minute NAC upgrades) should fire-and-poll, not hold the RPC open.
55
+ const RPC_TIMEOUT_MAX_MS = 280_000;
51
56
  const TOKEN_CACHE_TTL_MS = 60_000;
52
57
 
53
58
  // ─── Forge-token validation (with short-lived cache) ──────
@@ -112,17 +117,21 @@ interface PendingRpc {
112
117
 
113
118
  const pendingRpcs = new Map<string, PendingRpc>(); // rpc_id → callbacks
114
119
 
115
- function callExtension(method: string, params: unknown): Promise<unknown> {
120
+ function callExtension(method: string, params: unknown, timeoutMs?: number): Promise<unknown> {
116
121
  const client = pickAnyClient();
117
122
  if (!client) {
118
123
  return Promise.reject(new Error('No extension connected to the bridge.'));
119
124
  }
120
125
  const id = randomUUID();
126
+ const effectiveTimeout = Math.min(
127
+ Math.max(1000, Number(timeoutMs) || RPC_TIMEOUT_MS),
128
+ RPC_TIMEOUT_MAX_MS,
129
+ );
121
130
  return new Promise<unknown>((resolve, reject) => {
122
131
  const timer = setTimeout(() => {
123
132
  pendingRpcs.delete(id);
124
- reject(new Error(`RPC ${method} timed out after ${RPC_TIMEOUT_MS / 1000}s`));
125
- }, RPC_TIMEOUT_MS);
133
+ reject(new Error(`RPC ${method} timed out after ${effectiveTimeout / 1000}s`));
134
+ }, effectiveTimeout);
126
135
  pendingRpcs.set(id, { resolve, reject, timer });
127
136
  client.ws.send(JSON.stringify({ type: 'rpc_request', id, method, params }));
128
137
  });
@@ -248,7 +257,7 @@ async function handleHttp(req: IncomingMessage, res: ServerResponse): Promise<vo
248
257
  if (req.method === 'POST' && url.pathname === '/api/rpc') {
249
258
  try {
250
259
  const body = JSON.parse(await readBody(req));
251
- const value = await callExtension(body.method, body.params);
260
+ const value = await callExtension(body.method, body.params, body.timeout_ms);
252
261
  return sendJson(res, 200, { ok: true, value });
253
262
  } catch (e) {
254
263
  return sendJson(res, 200, { ok: false, error: (e as Error).message });
@@ -308,9 +308,9 @@ function buildConnectorTools(): LlmTool[] {
308
308
  for (const entry of getConnectorEntries(def)) {
309
309
  for (const [toolName, tool] of Object.entries(entry.tools || {})) {
310
310
  // Executable if it has a script (browser protocol) OR a non-browser
311
- // protocol that runs server-side (http / shell).
311
+ // protocol that runs server-side (http / shell / ssh).
312
312
  const protocol = (tool as any).protocol;
313
- const isServerSide = protocol === 'http' || protocol === 'shell';
313
+ const isServerSide = protocol === 'http' || protocol === 'shell' || protocol === 'ssh';
314
314
  if (!tool.script && !isServerSide) continue;
315
315
  const properties: Record<string, unknown> = {};
316
316
  const required: string[] = [];
@@ -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,
@@ -274,6 +275,30 @@ const BUILTINS: Record<string, BuiltinHandler> = {
274
275
  });
275
276
  return `Task dispatched: ${task.id} (project: ${project.name}, status: ${task.status}). Watch in the Tasks view.`;
276
277
  },
278
+
279
+ // List Forge's own help/documentation files so the chat agent can answer
280
+ // questions about Forge itself (features, config, troubleshooting) without
281
+ // the user opening the separate Help AI. Pair with read_help_doc.
282
+ list_help_docs: async () => {
283
+ const { listHelpDocs } = await import('../help-content');
284
+ const docs = listHelpDocs();
285
+ if (docs.length === 0) return 'No Forge help docs found.';
286
+ return 'Forge help docs (read one with read_help_doc):\n' + docs.map((d) => `- ${d}`).join('\n');
287
+ },
288
+
289
+ // Read one Forge help doc in full. Use to answer questions about Forge's
290
+ // own features / settings / troubleshooting.
291
+ read_help_doc: async (input) => {
292
+ const { doc } = (input as { doc?: string } | undefined) || {};
293
+ if (!doc) return 'read_help_doc failed: doc is required (a filename from list_help_docs, e.g. "05-pipelines.md").';
294
+ const { readHelpDoc } = await import('../help-content');
295
+ const content = readHelpDoc(doc);
296
+ if (content == null) return `Help doc "${doc}" not found. Call list_help_docs to see available filenames.`;
297
+ const MAX = 30000;
298
+ return content.length > MAX
299
+ ? content.slice(0, MAX) + `\n\n…[truncated — doc is ${content.length} chars]`
300
+ : content;
301
+ },
277
302
  };
278
303
 
279
304
  export interface BuiltinToolDef {
@@ -351,6 +376,25 @@ export const BUILTIN_TOOL_DEFS: BuiltinToolDef[] = [
351
376
  required: ['prompt'],
352
377
  },
353
378
  },
379
+ {
380
+ name: 'list_help_docs',
381
+ description: "List Forge's own documentation files. Call this FIRST whenever the user asks how Forge itself works — its features, settings/config, setup, or troubleshooting (e.g. pipelines, schedules, connectors, telegram, tunnel, workspace/smiths, skills, crafts, usage/cost, agents/models). Returns doc filenames; then read the relevant one(s) with read_help_doc and answer from their content. No arguments.",
382
+ input_schema: { type: 'object', properties: {} },
383
+ },
384
+ {
385
+ name: 'read_help_doc',
386
+ description: 'Read one Forge help doc in full (filename from list_help_docs, e.g. "05-pipelines.md"). Use its content to answer the user\'s question about Forge. Read more than one doc if the question spans topics.',
387
+ input_schema: {
388
+ type: 'object',
389
+ properties: {
390
+ doc: {
391
+ type: 'string',
392
+ description: 'Help doc filename, e.g. "05-pipelines.md" or "10-troubleshooting.md". Get exact names from list_help_docs.',
393
+ },
394
+ },
395
+ required: ['doc'],
396
+ },
397
+ },
354
398
  ];
355
399
 
356
400
  // ─── Connector dispatch ──────────────────────────────────
@@ -479,6 +523,18 @@ export async function dispatchTool(
479
523
  const protocol = located.tool.protocol || 'browser';
480
524
  const argInput = (call.input ?? {}) as Record<string, any>;
481
525
 
526
+ // Apply each parameter's `default` for keys the model omitted, so
527
+ // template tokens like {args.scp_host} resolve instead of staying
528
+ // literal. JSON-schema defaults are only advisory to the model — it
529
+ // routinely drops optional fields — so fill them here. Only sets
530
+ // missing/null; never overrides a value the model actually passed.
531
+ for (const [pname, pdef] of Object.entries(located.tool.parameters || {})) {
532
+ if (pdef && typeof pdef === 'object' && 'default' in (pdef as any)
533
+ && (argInput[pname] === undefined || argInput[pname] === null)) {
534
+ argInput[pname] = (pdef as any).default;
535
+ }
536
+ }
537
+
482
538
  // Multi-instance overlay: when a connector's settings carry a
483
539
  // `instances` array of `{name, ...}` objects, the tool's `instance`
484
540
  // arg picks one and its fields are merged into the top-level settings
@@ -516,6 +572,8 @@ export async function dispatchTool(
516
572
  return await runHttp({ tool: located.tool, settings: effectiveSettings, args: argInput, connectorAuth: def.auth, noTruncation: opts.noTruncation });
517
573
  case 'shell':
518
574
  return await runShell({ tool: located.tool, settings: effectiveSettings, args: argInput });
575
+ case 'ssh':
576
+ return await runSsh({ tool: located.tool, settings: effectiveSettings, args: argInput });
519
577
  case 'browser': {
520
578
  // Hand the whole connector + tool spec + input + settings to the
521
579
  // extension's runner.ts via the bridge. The extension keeps owning
@@ -527,7 +585,7 @@ export async function dispatchTool(
527
585
  input: argInput,
528
586
  connector,
529
587
  settings: effectiveSettings,
530
- })) as { content?: string; is_error?: boolean } | null;
588
+ }, located.tool.timeout_ms)) as { content?: string; is_error?: boolean } | null;
531
589
  return { content: result?.content ?? '(no content returned)', is_error: !!result?.is_error };
532
590
  }
533
591
  default:
@@ -14,7 +14,56 @@
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
+ /**
33
+ * `protocol: ssh` spec — drives an interactive SSH session via a PTY
34
+ * (the system `ssh` binary). Built for devices whose CLI needs a
35
+ * password + interactive confirmations (e.g. FortiNAC firmware restore).
36
+ * All string fields are templated with {settings.*}/{args.*}.
37
+ */
38
+ export interface SshSpec {
39
+ // Connection params are resolved by the runner with this precedence:
40
+ // tool arg (host/port/username/password) > connector setting
41
+ // (host/port/username/password) > the literal here > built-in default.
42
+ // So chat can pass them per-call and the connector holds defaults; the
43
+ // IP typically comes from chat only (no setting). All optional here.
44
+ host?: string;
45
+ /** Default 22. */
46
+ port?: string | number;
47
+ user?: string;
48
+ /** Password fed when a `password:` prompt appears (sent silently). */
49
+ password?: string;
50
+ /** Commands sent one-per-shell-prompt, in order (e.g. the upgrade cmd, then exit). */
51
+ commands?: string[];
52
+ /** Auto-answers applied throughout the session (e.g. `(y/N)` → `y`). */
53
+ auto_answer?: SshExpectRule[];
54
+ /** Shell-prompt regex that gates sending the next command. Default `[#$>]\s*$`. */
55
+ prompt_regex?: string;
56
+ /** Success marker regex — when seen, the session resolves ok and ssh is closed. */
57
+ done_when?: string;
58
+ /** Failure marker regex — when seen, resolves is_error. */
59
+ fail_when?: string;
60
+ /** name → regex(1 capture group) pulled from the full transcript into the result. */
61
+ capture?: Record<string, string>;
62
+ /** Overall timeout. Default 120s, max 280s. */
63
+ timeout_sec?: number;
64
+ /** Treat the remote closing the connection as success (e.g. after `exit`). */
65
+ success_on_close?: boolean;
66
+ }
18
67
 
19
68
  /** Schema for one settings or parameter field. */
20
69
  export interface ConnectorFieldSchema {
@@ -170,7 +219,18 @@ export interface ConnectorTool {
170
219
  /** Extra env vars (values templated). */
171
220
  env?: Record<string, string>;
172
221
 
173
- /** shell/http: timeout in milliseconds. Default 30000, max 300000. */
222
+ // ── protocol: 'ssh' ───────────────────────────────────────
223
+ /** Interactive SSH session spec (PTY-driven). See SshSpec. */
224
+ ssh?: SshSpec;
225
+
226
+ /**
227
+ * Timeout in milliseconds.
228
+ * - shell/http: request timeout (default 30000, max 300000).
229
+ * - browser: how long the bridge waits for the extension to return the
230
+ * RPC result (default 60000, capped at 900000). Raise it for tools
231
+ * whose script issues a long synchronous backend call (e.g. a NAC
232
+ * upgrade that blocks for minutes).
233
+ */
174
234
  timeout_ms?: number;
175
235
 
176
236
  /**
@@ -0,0 +1,51 @@
1
+ /**
2
+ * Help-doc reader for the chat agent's read_help_doc / list_help_docs tools.
3
+ *
4
+ * Reads the markdown docs that ship under lib/help-docs/ (same set the Help AI
5
+ * terminal uses). Resolved relative to this module so it works regardless of
6
+ * whether the on-disk sync (ensureHelpDocs → <config>/help) has run yet, then
7
+ * falls back to the synced copy if the source tree isn't present (installed
8
+ * tarball layouts).
9
+ */
10
+
11
+ import { readFileSync, readdirSync, existsSync } from 'node:fs';
12
+ import { join } from 'node:path';
13
+ import { fileURLToPath } from 'node:url';
14
+ import { getConfigDir } from './dirs';
15
+
16
+ // Candidate dirs, in priority order: source tree next to this module, then the
17
+ // synced runtime copy under ~/.forge/help.
18
+ function helpDirs(): string[] {
19
+ const dirs: string[] = [];
20
+ try {
21
+ const here = fileURLToPath(new URL('./help-docs', import.meta.url));
22
+ dirs.push(here);
23
+ } catch { /* import.meta.url unavailable (CJS bundle) — skip */ }
24
+ dirs.push(join(getConfigDir(), 'help'));
25
+ return dirs;
26
+ }
27
+
28
+ /** List available help-doc filenames (e.g. ["00-overview.md", ...]), sorted. */
29
+ export function listHelpDocs(): string[] {
30
+ for (const dir of helpDirs()) {
31
+ try {
32
+ if (!existsSync(dir)) continue;
33
+ const files = readdirSync(dir).filter((f) => f.endsWith('.md')).sort();
34
+ if (files.length) return files;
35
+ } catch { /* try next dir */ }
36
+ }
37
+ return [];
38
+ }
39
+
40
+ /** Read one help doc by filename. Returns null if not found. Path-traversal safe. */
41
+ export function readHelpDoc(name: string): string | null {
42
+ const base = (name || '').split(/[\\/]/).pop() || '';
43
+ if (!base.endsWith('.md')) return null;
44
+ for (const dir of helpDirs()) {
45
+ try {
46
+ const file = join(dir, base);
47
+ if (existsSync(file)) return readFileSync(file, 'utf-8');
48
+ } catch { /* try next dir */ }
49
+ }
50
+ return null;
51
+ }
@@ -46,7 +46,6 @@ Each agent entry in `settings.agents` supports:
46
46
  | `enabled` | boolean | Whether this agent is available |
47
47
  | `cliType` | string | CLI tool type: `claude-code`, `codex`, `aider`, `generic` |
48
48
  | `taskFlags` | string | Flags for headless task execution (e.g. `-p --verbose`) |
49
- | `interactiveCmd` | string | Command for interactive terminal sessions |
50
49
  | `resumeFlag` | string | Flag to resume sessions (e.g. `-c` for claude) |
51
50
  | `outputFormat` | string | Output format: `stream-json`, `text` |
52
51
  | `skipPermissionsFlag` | string | Flag to skip permissions (e.g. `--dangerously-skip-permissions`) |
package/lib/settings.ts CHANGED
@@ -23,7 +23,7 @@ const SETTINGS_FILE = join(DATA_DIR, 'settings.yaml');
23
23
  export interface AgentEntry {
24
24
  tool?: 'claude' | 'codex' | 'aider' | 'opencode'; // which CLI binary
25
25
  path?: string; name?: string; enabled?: boolean;
26
- flags?: string[]; taskFlags?: string; interactiveCmd?: string; resumeFlag?: string; outputFormat?: string;
26
+ flags?: string[]; taskFlags?: string; resumeFlag?: string; outputFormat?: string;
27
27
  models?: { terminal?: string; task?: string; telegram?: string; help?: string; mobile?: string };
28
28
  skipPermissionsFlag?: string;
29
29
  requiresTTY?: boolean;
@@ -1086,7 +1086,7 @@ function getSessionPreview(sessionName: string, maxLines: number = 1): string {
1086
1086
  try {
1087
1087
  const { execSync } = require('node:child_process');
1088
1088
  // Capture last screen, strip ANSI escapes, find last non-empty lines
1089
- const out = execSync(`tmux capture-pane -t "${sessionName}" -p -S -50 2>/dev/null`, {
1089
+ const out = execSync(`tmux capture-pane -t "${sessionName}" -p -S -100 2>/dev/null`, {
1090
1090
  encoding: 'utf-8',
1091
1091
  timeout: 2000,
1092
1092
  }) as string;
@@ -1101,8 +1101,8 @@ function getSessionPreview(sessionName: string, maxLines: number = 1): string {
1101
1101
  const last = tail[0];
1102
1102
  return last.length > 30 ? last.slice(0, 30) + '…' : last;
1103
1103
  }
1104
- // Multi-line: truncate each line to 80 chars
1105
- return tail.map(l => l.length > 80 ? l.slice(0, 80) + '…' : l).join('\n');
1104
+ // Multi-line: truncate each line to 160 chars
1105
+ return tail.map(l => l.length > 160 ? l.slice(0, 160) + '…' : l).join('\n');
1106
1106
  } catch {
1107
1107
  return '';
1108
1108
  }
@@ -1185,8 +1185,8 @@ async function pickInjectTarget(chatId: number, numStr: string) {
1185
1185
  scheduleInjectAutoClear(chatId);
1186
1186
  const labelMap = getSessionLabels();
1187
1187
  const display = labelMap[sessionName] || sessionName.replace(/^mw-?/, '');
1188
- // Show last 8 lines of context so user knows what's in the terminal
1189
- const context = getSessionPreview(sessionName, 8);
1188
+ // Show last 16 lines of context so user knows what's in the terminal
1189
+ const context = getSessionPreview(sessionName, 16);
1190
1190
  const contextBlock = context ? `\n\n📺 Last output:\n\`\`\`\n${context}\n\`\`\`` : '';
1191
1191
  await send(chatId,
1192
1192
  `🎯 Target: ${display}${contextBlock}\n\n` +
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aion0/forge",
3
- "version": "0.10.18",
3
+ "version": "0.10.22",
4
4
  "description": "Unified AI workflow platform — multi-model task orchestration, persistent sessions, web terminal, remote access",
5
5
  "type": "module",
6
6
  "scripts": {