@aion0/forge 0.10.64 → 0.10.66

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/RELEASE_NOTES.md CHANGED
@@ -1,8 +1,11 @@
1
- # Forge v0.10.64
1
+ # Forge v0.10.66
2
2
 
3
3
  Released: 2026-06-10
4
4
 
5
- ## Changes since v0.10.63
5
+ ## Changes since v0.10.65
6
6
 
7
+ ### Other
8
+ - fix(login-status): IdP probe honors auth_required_selector — no more same-host login-page false positives
7
9
 
8
- **Full Changelog**: https://github.com/aiwatching/forge/compare/v0.10.63...v0.10.64
10
+
11
+ **Full Changelog**: https://github.com/aiwatching/forge/compare/v0.10.65...v0.10.66
package/app/chat/page.tsx CHANGED
@@ -206,12 +206,42 @@ export default function ChatPage() {
206
206
  };
207
207
  }, [activeId, loadMessages, refreshSessions]);
208
208
 
209
- // ─── Auto-scroll on new content ───────────────────────────
209
+ // ─── Auto-scroll on new content — only when already at the bottom ──
210
+ // stickRef tracks whether the user is parked near the bottom. If they've
211
+ // scrolled up to read earlier turns, a refresh / new streamed chunk must
212
+ // NOT yank them back down. Updated on every scroll (see onScroll below).
213
+ const stickRef = useRef(true);
210
214
  useEffect(() => {
211
215
  const el = scrollRef.current;
212
- if (el) el.scrollTop = el.scrollHeight;
216
+ if (el && stickRef.current) el.scrollTop = el.scrollHeight;
213
217
  }, [messages, partial]);
214
218
 
219
+ // ─── Jump markers: one tick per user turn along the scroll track ──
220
+ const [markers, setMarkers] = useState<{ mid: string; pct: number; offset: number; label: string }[]>([]);
221
+ const recomputeMarkers = useCallback(() => {
222
+ const el = scrollRef.current;
223
+ if (!el) return;
224
+ const h = el.scrollHeight || 1;
225
+ const nodes = el.querySelectorAll<HTMLElement>('[data-role="user"][data-mid]');
226
+ setMarkers([...nodes].map((n) => ({
227
+ mid: n.dataset.mid || '',
228
+ offset: n.offsetTop,
229
+ pct: (n.offsetTop / h) * 100,
230
+ label: (n.dataset.label || '').slice(0, 80),
231
+ })));
232
+ }, []);
233
+ useEffect(() => {
234
+ const id = requestAnimationFrame(recomputeMarkers);
235
+ return () => cancelAnimationFrame(id);
236
+ }, [messages, partial, recomputeMarkers]);
237
+ useEffect(() => {
238
+ const el = scrollRef.current;
239
+ if (!el || typeof ResizeObserver === 'undefined') return;
240
+ const ro = new ResizeObserver(() => recomputeMarkers());
241
+ ro.observe(el);
242
+ return () => ro.disconnect();
243
+ }, [recomputeMarkers]);
244
+
215
245
  // ─── Prune stale watch chips (no update in >150s = done/gone) ──
216
246
  useEffect(() => {
217
247
  const t = setInterval(() => {
@@ -643,7 +673,15 @@ export default function ChatPage() {
643
673
  </button>
644
674
  </header>
645
675
 
646
- <div ref={scrollRef} className="flex-1 overflow-y-auto">
676
+ <div className="flex-1 relative min-h-0">
677
+ <div
678
+ ref={scrollRef}
679
+ className="absolute inset-0 overflow-y-auto"
680
+ onScroll={(e) => {
681
+ const el = e.currentTarget;
682
+ stickRef.current = el.scrollHeight - el.scrollTop - el.clientHeight < 80;
683
+ }}
684
+ >
647
685
  <div className="max-w-3xl mx-auto px-6 py-6 space-y-6">
648
686
  {messages.length === 0 && !partial && !streaming && (
649
687
  <div className="text-center text-sm text-[var(--text-secondary)] mt-12">
@@ -672,6 +710,25 @@ export default function ChatPage() {
672
710
  )}
673
711
  </div>
674
712
  </div>
713
+ {/* Jump rail — one tick per user turn, mapped to its position in the
714
+ scroll content. Click to jump. Hidden until there are ≥2 turns. */}
715
+ {markers.length >= 2 && (
716
+ <div className="absolute right-0.5 top-2 bottom-2 w-3 z-10 pointer-events-none">
717
+ {markers.map((mk) => (
718
+ <button
719
+ key={mk.mid}
720
+ title={mk.label || 'jump to message'}
721
+ onClick={() => {
722
+ const el = scrollRef.current;
723
+ if (el) el.scrollTo({ top: Math.max(0, mk.offset - 12), behavior: 'smooth' });
724
+ }}
725
+ className="pointer-events-auto absolute right-0 -translate-y-1/2 h-1.5 w-1.5 rounded-full bg-[var(--text-secondary)] opacity-40 hover:opacity-100 hover:w-2.5 hover:bg-[var(--accent)] transition-all"
726
+ style={{ top: `${Math.min(99, Math.max(1, mk.pct))}%` }}
727
+ />
728
+ ))}
729
+ </div>
730
+ )}
731
+ </div>
675
732
 
676
733
  {Object.keys(watchChips).length > 0 && (
677
734
  <div className="px-6 pt-2">
@@ -775,7 +832,7 @@ function fmtTs(ts?: number): string {
775
832
  return sameDay ? time : `${d.toLocaleDateString([], { month: 'short', day: 'numeric' })} ${time}`;
776
833
  }
777
834
 
778
- function RoleBlock({ role, ts, pending, children }: { role: 'user' | 'assistant'; ts?: number; pending?: boolean; children: React.ReactNode }) {
835
+ function RoleBlock({ role, ts, pending, mid, label, children }: { role: 'user' | 'assistant'; ts?: number; pending?: boolean; mid?: string; label?: string; children: React.ReactNode }) {
779
836
  const isUser = role === 'user';
780
837
  // User → right-aligned with bubble; assistant → left-aligned (avatar +
781
838
  // expanded content for markdown / tool cards). Pending optimistic notes
@@ -783,7 +840,7 @@ function RoleBlock({ role, ts, pending, children }: { role: 'user' | 'assistant'
783
840
  // replaces them on the loop's next iteration.
784
841
  if (isUser) {
785
842
  return (
786
- <div className="flex justify-end">
843
+ <div className="flex justify-end" data-role="user" data-mid={mid} data-label={label}>
787
844
  <div className="max-w-[80%]">
788
845
  <div className="flex items-baseline gap-2 mb-1 justify-end">
789
846
  {pending ? (
@@ -834,8 +891,12 @@ function RoleBlock({ role, ts, pending, children }: { role: 'user' | 'assistant'
834
891
  // chat became visibly laggy.
835
892
  const MessageView = memo(function MessageView({ m }: { m: Message }) {
836
893
  const pending = typeof m.id === 'string' && m.id.startsWith('optimistic-note-');
894
+ // Label for the jump rail = first text block of a user turn, trimmed.
895
+ const label = m.role === 'user'
896
+ ? (m.blocks.find((b) => b.type === 'text')?.text || '').replace(/\s+/g, ' ').trim().slice(0, 80)
897
+ : undefined;
837
898
  return (
838
- <RoleBlock role={m.role} ts={m.ts} pending={pending}>
899
+ <RoleBlock role={m.role} ts={m.ts} pending={pending} mid={String(m.id)} label={label}>
839
900
  {m.blocks.map((b, i) => (
840
901
  <BlockView key={i} b={b} role={m.role} />
841
902
  ))}
@@ -1799,7 +1799,7 @@ function MarketplaceProvidersSection() {
1799
1799
  }
1800
1800
 
1801
1801
  function AgentsSection({ settings, setSettings }: { settings: any; setSettings: (s: any) => void }) {
1802
- const { registry: modelsRegistry } = useModelsRegistry();
1802
+ const { registry: modelsRegistry, refresh: refreshModels, loading: modelsLoading } = useModelsRegistry();
1803
1803
  const [agents, setAgents] = useState<AgentEntry[]>([]);
1804
1804
  const [loading, setLoading] = useState(true);
1805
1805
  const [expandedAgent, setExpandedAgent] = useState<string | null>(null);
@@ -2126,6 +2126,13 @@ function AgentsSection({ settings, setSettings }: { settings: any; setSettings:
2126
2126
  {/* Preset models */}
2127
2127
  <div className="flex items-center gap-1 mt-1.5 flex-wrap">
2128
2128
  <span className="text-[8px] text-[var(--text-secondary)]">Presets:</span>
2129
+ <button
2130
+ type="button"
2131
+ onClick={() => { void refreshModels(); }}
2132
+ disabled={modelsLoading}
2133
+ title="Refresh model list from the public-info registry (bypasses the 24h cache)"
2134
+ className="text-[8px] px-1 py-0.5 rounded border border-[var(--border)] text-[var(--text-secondary)] hover:text-[var(--text-primary)] disabled:opacity-50"
2135
+ >{modelsLoading ? '…' : '↻'}</button>
2129
2136
  {((() => {
2130
2137
  const ct = (settings.agents?.[a.id] as any)?.cliType || (a.id === 'claude' ? 'claude-code' : a.id === 'codex' ? 'codex' : a.id === 'aider' ? 'aider' : 'generic');
2131
2138
  // Models pulled from forge-public-info repo (lib/public-info/fetch.ts).
@@ -56,6 +56,11 @@ export interface IdpTemplateBlock {
56
56
  * `https://<host>/`. Override when the IdP's logged-in landing page
57
57
  * is at a specific path (e.g. `/saml-idp/portal/` for FAC). */
58
58
  probe_url?: string;
59
+ /** DOM selector present ONLY on the login page (e.g. 'input[type=password]').
60
+ * Required for IdPs that serve their login form on the SAME host as the
61
+ * logged-in page (sso.frval.fortinet-emea.com) — without it the host-only
62
+ * probe falsely reports "signed in" when a login screen is showing. */
63
+ auth_required_selector?: string;
59
64
  /** Optional alt hostnames the IdP also uses (e.g. regional SAML servers). */
60
65
  alt_hosts?: string[];
61
66
  saml_sps?: string[];
@@ -235,7 +235,7 @@ async function checkIdp(
235
235
  const block = readIdpBlocks().find((b) => b.host === host);
236
236
  const probe_url = block ? idpProbeUrl(block) : `https://${host}/`;
237
237
  try {
238
- const r = await runBrowserUrlProbe({ id: `idp:${host}`, host, probe_url });
238
+ const r = await runBrowserUrlProbe({ id: `idp:${host}`, host, probe_url, auth_required_selector: block?.auth_required_selector });
239
239
  return {
240
240
  ok: !!r.ok,
241
241
  message: r.ok ? (r.message || 'ok') : (r.error || `HTTP ${r.status || '?'}`),
@@ -116,6 +116,53 @@ async function resolveScratchRefsInArgs(args: Record<string, unknown>): Promise<
116
116
  }
117
117
  }
118
118
 
119
+ // Download channel (mirror of the scratch:// upload path). A connector that
120
+ // fetched a binary (e.g. owa.download_attachment) can't return the bytes
121
+ // through the model — the 16KB tool-result cap would shred any base64. So it
122
+ // returns `_files: [{ filename, content_base64, content_type }]` and we
123
+ // materialize them HERE: decode → write to <dataDir>/tmp/ → strip the base64
124
+ // and replace each entry with a small {filename, path, file_url, size_bytes}
125
+ // pointer the model can hand to read_forge_file / extract_archive. Bytes
126
+ // never reach the LLM. Generic — any protocol's result is scanned.
127
+ async function materializeConnectorFiles(result: ToolResult): Promise<ToolResult> {
128
+ let parsed: unknown;
129
+ try { parsed = JSON.parse(result.content); } catch { return result; }
130
+ if (!parsed || typeof parsed !== 'object') return result;
131
+ const files = (parsed as { _files?: unknown })._files;
132
+ if (!Array.isArray(files) || files.length === 0) return result;
133
+
134
+ const { mkdir, writeFile } = await import('node:fs/promises');
135
+ const { dirname, basename } = await import('node:path');
136
+ const out: Array<Record<string, unknown>> = [];
137
+ for (const f of files) {
138
+ const e = (f && typeof f === 'object') ? f as Record<string, unknown> : {};
139
+ const b64 = typeof e.content_base64 === 'string' ? e.content_base64 : '';
140
+ const rawName = String(e.filename || e.name || 'download.bin');
141
+ // Bare, sanitized name under tmp/ — no traversal, no nesting (the cache
142
+ // janitor only sweeps top-level tmp/ files).
143
+ const safe = (basename(rawName).replace(/[\0/\\]/g, '_').replace(/^\.+/, '') || 'download.bin').slice(0, 200);
144
+ if (!b64) { out.push({ ...e, error: 'no content_base64' }); continue; }
145
+ try {
146
+ const buf = Buffer.from(b64, 'base64');
147
+ const target = await resolveTmpPath(safe);
148
+ await mkdir(dirname(target), { recursive: true });
149
+ await writeFile(target, buf);
150
+ out.push({
151
+ filename: safe,
152
+ path: `tmp/${safe}`,
153
+ local_path: target,
154
+ file_url: `file://${target}`,
155
+ size_bytes: buf.length,
156
+ ...(e.content_type ? { content_type: e.content_type } : {}),
157
+ });
158
+ } catch (err) {
159
+ out.push({ filename: safe, error: (err as Error).message });
160
+ }
161
+ }
162
+ const next = { ...(parsed as Record<string, unknown>), _files: out };
163
+ return { ...result, content: JSON.stringify(next) };
164
+ }
165
+
119
166
  const BUILTINS: Record<string, BuiltinHandler> = {
120
167
  get_current_time: async () => new Date().toISOString(),
121
168
 
@@ -475,6 +522,82 @@ const BUILTINS: Record<string, BuiltinHandler> = {
475
522
  });
476
523
  },
477
524
 
525
+ // Extract a zip/tar/gz archive sitting in tmp/ (e.g. one a connector just
526
+ // downloaded via the _files channel) into tmp/<base>-extracted/, then
527
+ // return the file listing so the agent can read_forge_file each entry.
528
+ // The chat agent has no shell, so this shells out to system unzip/tar/
529
+ // gunzip on its behalf. Stays inside tmp/ on both ends.
530
+ extract_archive: async (input) => {
531
+ const params = (input as { filename?: string } | undefined) || {};
532
+ const raw = String(params.filename || '').trim().replace(/^tmp\//, '');
533
+ if (!raw) return JSON.stringify({ ok: false, error: 'filename is required (a tmp/ archive, e.g. "report.zip")' });
534
+ let archive: string;
535
+ try { archive = await resolveTmpPath(raw); }
536
+ catch (e) { return JSON.stringify({ ok: false, error: (e as Error).message }); }
537
+ const { existsSync } = await import('node:fs');
538
+ if (!existsSync(archive)) return JSON.stringify({ ok: false, error: `archive not found: tmp/${raw}` });
539
+
540
+ const lower = raw.toLowerCase();
541
+ const base = raw.replace(/\.(zip|tar\.gz|tgz|tar|gz)$/i, '');
542
+ const outRel = `${base}-extracted`;
543
+ let outDir: string;
544
+ try { outDir = await resolveTmpPath(outRel); }
545
+ catch (e) { return JSON.stringify({ ok: false, error: (e as Error).message }); }
546
+
547
+ const { execFile } = await import('node:child_process');
548
+ const { promisify } = await import('node:util');
549
+ const { mkdir, readdir, stat } = await import('node:fs/promises');
550
+ const { join, relative } = await import('node:path');
551
+ const run = promisify(execFile);
552
+ await mkdir(outDir, { recursive: true });
553
+
554
+ try {
555
+ if (lower.endsWith('.zip')) {
556
+ await run('unzip', ['-o', '-qq', archive, '-d', outDir], { timeout: 60000 });
557
+ } else if (lower.endsWith('.tar.gz') || lower.endsWith('.tgz') || lower.endsWith('.tar')) {
558
+ await run('tar', ['-xf', archive, '-C', outDir], { timeout: 60000 });
559
+ } else if (lower.endsWith('.gz')) {
560
+ // single-file gzip → decompress to the same basename inside outDir
561
+ const { createReadStream, createWriteStream } = await import('node:fs');
562
+ const { createGunzip } = await import('node:zlib');
563
+ const { pipeline } = await import('node:stream/promises');
564
+ const dest = join(outDir, base.split('/').pop() || 'file');
565
+ await pipeline(createReadStream(archive), createGunzip(), createWriteStream(dest));
566
+ } else {
567
+ return JSON.stringify({ ok: false, error: `unsupported archive type: ${raw} (supported: .zip .tar .tar.gz .tgz .gz)` });
568
+ }
569
+ } catch (e) {
570
+ return JSON.stringify({ ok: false, error: `extraction failed: ${(e as Error).message}` });
571
+ }
572
+
573
+ // Walk the extracted tree (capped) and return tmp-relative paths.
574
+ const files: Array<{ path: string; size_bytes: number }> = [];
575
+ const { getDataDir } = await import('../dirs');
576
+ const tmpRoot = join(getDataDir(), 'tmp');
577
+ async function walk(dir: string, depth: number): Promise<void> {
578
+ if (files.length >= 500 || depth > 8) return;
579
+ for (const ent of await readdir(dir, { withFileTypes: true })) {
580
+ if (files.length >= 500) break;
581
+ const full = join(dir, ent.name);
582
+ if (ent.isDirectory()) { await walk(full, depth + 1); continue; }
583
+ if (ent.isFile()) {
584
+ const s = await stat(full);
585
+ files.push({ path: `tmp/${relative(tmpRoot, full)}`, size_bytes: s.size });
586
+ }
587
+ }
588
+ }
589
+ try { await walk(outDir, 0); } catch { /* partial listing is fine */ }
590
+
591
+ return JSON.stringify({
592
+ ok: true,
593
+ archive: `tmp/${raw}`,
594
+ extracted_dir: `tmp/${outRel}`,
595
+ count: files.length,
596
+ files,
597
+ note: 'Read any entry with read_forge_file (pass its `path`). Extracted dir is NOT auto-swept by the cache janitor (it only sweeps top-level tmp/ files).',
598
+ });
599
+ },
600
+
478
601
  // List files anywhere under <dataDir>/ (Forge's own data dir).
479
602
  // Replaces the "dispatch a task just to `ls`" workaround. The `dir`
480
603
  // param is dataDir-relative — pass "tmp" / "scratch" / "flows" /
@@ -626,8 +749,11 @@ const BUILTINS: Record<string, BuiltinHandler> = {
626
749
  const p = (input as any) || {};
627
750
  const name = String(p.name || '').trim();
628
751
  const workflow = String(p.workflow || p.body_ref || '').trim();
752
+ const promptText = String(p.prompt || '').trim();
629
753
  if (!name) return JSON.stringify({ ok: false, error: 'name is required' });
630
- if (!workflow) return JSON.stringify({ ok: false, error: 'workflow (pipeline name) is required' });
754
+ if (!workflow && !promptText) {
755
+ return JSON.stringify({ ok: false, error: 'provide either prompt (free-text instructions — dispatched as a one-shot Claude task WITH connector tools) or workflow (an existing pipeline name)' });
756
+ }
631
757
 
632
758
  // Trigger normalization: prefer every_minutes; accept at (once) or cron.
633
759
  let schedule_kind: 'period' | 'once' | 'cron' = 'period';
@@ -647,14 +773,36 @@ const BUILTINS: Record<string, BuiltinHandler> = {
647
773
  return JSON.stringify({ ok: false, error: 'one of every_minutes / at / cron is required' });
648
774
  }
649
775
 
776
+ // Body resolution. prompt mode (preferred for "run these instructions on
777
+ // a schedule") wins when both are somehow given — it dispatches a one-shot
778
+ // Claude task that HAS the full connector toolset (Teams/Mantis/etc.), so
779
+ // no pipeline YAML is needed. The prompt text is persisted to
780
+ // prompts/<slug>.yaml and the schedule row just references it by name.
781
+ let body_kind: 'pipeline' | 'prompt' = 'pipeline';
782
+ let body_ref = workflow;
783
+ const skills = Array.isArray(p.skills) ? p.skills : undefined;
784
+ if (promptText) {
785
+ const slug = (name.toLowerCase().replace(/[^a-z0-9._-]+/g, '-').replace(/^-+|-+$/g, '') || 'schedule').slice(0, 64);
786
+ try {
787
+ const { createPrompt, getPrompt, updatePrompt } = await import('../prompts/store');
788
+ const execu = skills ? { skills } : undefined;
789
+ if (getPrompt(slug)) updatePrompt(slug, { prompt: promptText, executor: execu });
790
+ else createPrompt({ name: slug, prompt: promptText, executor: execu });
791
+ } catch (e) {
792
+ return JSON.stringify({ ok: false, error: `failed to save prompt: ${(e as Error).message}` });
793
+ }
794
+ body_kind = 'prompt';
795
+ body_ref = slug;
796
+ }
797
+
650
798
  const { createSchedule, seedNextRunAt } = await import('../schedules/store');
651
799
  try {
652
800
  const s = createSchedule({
653
801
  name,
654
- body_kind: 'pipeline',
655
- body_ref: workflow,
802
+ body_kind,
803
+ body_ref,
656
804
  input: (p.input && typeof p.input === 'object') ? p.input : {},
657
- skills: Array.isArray(p.skills) ? p.skills : undefined,
805
+ skills,
658
806
  enabled: p.enabled !== false,
659
807
  schedule_kind,
660
808
  schedule_interval_minutes,
@@ -889,6 +1037,17 @@ export const BUILTIN_TOOL_DEFS: BuiltinToolDef[] = [
889
1037
  required: ['filename'],
890
1038
  },
891
1039
  },
1040
+ {
1041
+ name: 'extract_archive',
1042
+ description: 'Unpack an archive sitting in tmp/ (e.g. one a connector just downloaded — owa.download_attachment etc.) into tmp/<base>-extracted/, and return the file listing. USE THIS when an attachment / download is a .zip / .tar / .tar.gz / .tgz / .gz and you need to read what is inside — you have no shell, this runs unzip/tar for you. Then read individual entries with read_forge_file using each returned `path`. Returns JSON {ok, extracted_dir, count, files:[{path,size_bytes}]}.',
1043
+ input_schema: {
1044
+ type: 'object',
1045
+ properties: {
1046
+ filename: { type: 'string', description: 'tmp/ archive to extract, e.g. "report.zip" or "tmp/logs.tar.gz". Must already be in tmp/ (a download lands there via the connector _files channel).' },
1047
+ },
1048
+ required: ['filename'],
1049
+ },
1050
+ },
892
1051
  {
893
1052
  name: 'list_forge_files',
894
1053
  description: 'List files / subdirs anywhere under <dataDir>/ (Forge\'s own data dir). PREFER THIS over `dispatch_task` with `ls` — it\'s sync, in-process, no LLM detour. `dir` is dataDir-relative: pass "tmp" for chat-saved temp files, "scratch" for task workspaces, "flows" / "prompts" / "connectors" for configs. Omit `dir` to see the dataDir root. Each entry has `path` (dataDir-relative), `kind` (file/dir), and `file_url` (file://… opens in Chrome on localhost). Sensitive items (encrypt key, sqlite DBs, server log, *-tokens.json) are filtered out automatically.',
@@ -947,12 +1106,13 @@ export const BUILTIN_TOOL_DEFS: BuiltinToolDef[] = [
947
1106
  },
948
1107
  {
949
1108
  name: 'create_schedule',
950
- description: 'Create a recurring (or one-off) schedule that fires a Forge pipeline on a timer. NO HTTP, NO auth — runs in-process. Use this when the user says "every N minutes/hours" / "watch X" / "monitor Y" / "auto-run pipeline on schedule". REQUIRED args: name + workflow + ONE of {every_minutes, at, cron}. Returns { ok, schedule_id, next_run_at }.',
1109
+ description: 'Create a recurring (or one-off) schedule on a timer. NO HTTP, NO auth — runs in-process. Use when the user says "every N minutes/hours" / "watch X" / "monitor Y" / "auto-run on schedule". TWO body modes — pick ONE:\n• prompt mode (PREFERRED for "do these instructions on a schedule"): pass `prompt` with free-text instructions. Each fire dispatches a one-shot Claude task that HAS THE FULL CONNECTOR TOOLSET (Teams/Mantis/GitLab/etc.) and your skills. No pipeline YAML needed — do NOT build a throwaway pipeline just to run a prompt on a timer.\n• pipeline mode: pass `workflow` (an existing pipeline name) for a structured multi-node DAG.\nREQUIRED: name + (prompt OR workflow) + ONE of {every_minutes, at, cron}. Returns { ok, schedule_id, next_run_at }.',
951
1110
  input_schema: {
952
1111
  type: 'object',
953
1112
  properties: {
954
- name: { type: 'string', description: 'Human-readable name shown in the Schedules UI.' },
955
- workflow: { type: 'string', description: 'Pipeline workflow name (file basename of flows/<name>.yaml). Run trigger_pipeline() with NO args first if unsure what names are available.' },
1113
+ name: { type: 'string', description: 'Human-readable name shown in the Schedules UI. Also used as the prompt slug in prompt mode.' },
1114
+ prompt: { type: 'string', description: 'PROMPT MODE: free-text instructions run as a one-shot Claude task on each fire, with full connector tools + skills. Use this for "every hour check Teams channel X and notify me" style asks. Mutually exclusive with workflow.' },
1115
+ workflow: { type: 'string', description: 'PIPELINE MODE: existing pipeline workflow name (basename of flows/<name>.yaml). Run trigger_pipeline() with NO args first to list. Mutually exclusive with prompt.' },
956
1116
  input: { type: 'object', description: 'Pipeline input fields. Same shape as trigger_pipeline.input. OMIT optional fields to use defaults.' },
957
1117
  skills: { type: 'array', items: { type: 'string' }, description: 'Skill names to inject into every Claude task this schedule spawns.' },
958
1118
  every_minutes: { type: 'number', description: 'Period in minutes (e.g. 60 = hourly). Most common trigger.' },
@@ -961,7 +1121,7 @@ export const BUILTIN_TOOL_DEFS: BuiltinToolDef[] = [
961
1121
  enabled: { type: 'boolean', description: 'Whether to start enabled. Default true.' },
962
1122
  action: { type: 'string', enum: ['none', 'chat', 'email', 'telegram'], description: 'Post-run notification action. Default "none".' },
963
1123
  },
964
- required: ['name', 'workflow'],
1124
+ required: ['name'],
965
1125
  },
966
1126
  },
967
1127
  {
@@ -1374,6 +1534,14 @@ export async function dispatchTool(
1374
1534
  return { content: `unknown protocol "${protocol}" on tool ${call.name}`, is_error: true };
1375
1535
  }
1376
1536
 
1537
+ // Download channel: materialize any `_files` base64 payload to tmp/ and
1538
+ // strip the bytes before they reach the model (and before the watch
1539
+ // below re-parses the result). No-op when the result has no _files.
1540
+ if (!result.is_error) {
1541
+ try { result = await materializeConnectorFiles(result); }
1542
+ catch (e) { console.warn('[dispatch] materializeConnectorFiles failed', (e as Error).message); }
1543
+ }
1544
+
1377
1545
  // Async (long-task watch): if the tool declared an `async` block and
1378
1546
  // it ran without error, register a background watch that polls to
1379
1547
  // completion and reports back to the originating chat session. The
@@ -335,11 +335,16 @@ export async function fetchSourceFile(source: SourceMeta, relPath: string): Prom
335
335
  // directly (no base64 decode needed). Works for private repos given
336
336
  // a Fine-grained PAT with Contents: read.
337
337
  const repo = (source.repo_url || '').replace(/^github\.com\//, '');
338
- const url = `https://api.github.com/repos/${repo}/contents/${relPath}?ref=main`;
338
+ // &_t + no-cache: the GitHub contents API edge-caches the ?ref=main blob,
339
+ // so right after a push a plain fetch can return the STALE file — which
340
+ // made the marketplace keep showing old connector versions even after
341
+ // "↻ Sync all". The raw mode already busts via cacheBust(); match it here.
342
+ const url = `https://api.github.com/repos/${repo}/contents/${relPath}?ref=main&_t=${Date.now()}`;
339
343
  try {
340
344
  return await rawFetch(url, {
341
345
  Authorization: `Bearer ${source.github_pat}`,
342
346
  Accept: 'application/vnd.github.raw',
347
+ 'Cache-Control': 'no-cache',
343
348
  });
344
349
  } catch (err) {
345
350
  // GitHub returns 404 (not 401) for private repos when the PAT
@@ -283,6 +283,12 @@ export async function runBrowserUrlProbe(opts: {
283
283
  id: string; // identifier for telemetry, e.g. 'idp:fac.corp.fortinet.com'
284
284
  host: string; // expected host (e.g. 'fac.corp.fortinet.com')
285
285
  probe_url: string; // URL to navigate to (e.g. 'https://fac.corp.fortinet.com/saml-idp/portal/')
286
+ // DOM selector present ONLY on the login page (e.g. 'input[type=password]').
287
+ // Required for IdPs that serve their login form on the SAME host as the
288
+ // logged-in page (sso.frval.fortinet-emea.com), where the host-only check
289
+ // can't tell logged-in from a login screen. Extension v0.2.15+ runs the
290
+ // querySelector and returns ok:false on a match.
291
+ auth_required_selector?: string;
286
292
  timeout_ms?: number;
287
293
  }): Promise<TestResult> {
288
294
  const t0 = Date.now();
@@ -291,6 +297,7 @@ export async function runBrowserUrlProbe(opts: {
291
297
  value = await bridgeRpc('connector.probe', {
292
298
  pluginId: opts.id,
293
299
  host_match: opts.probe_url, // extension navigates to this URL directly
300
+ auth_required_selector: opts.auth_required_selector || undefined,
294
301
  runner: 'main',
295
302
  timeout_ms: opts.timeout_ms || 15_000,
296
303
  });
@@ -303,8 +310,14 @@ export async function runBrowserUrlProbe(opts: {
303
310
  // Pass if landed host matches expected. A redirect to a different host
304
311
  // (e.g. fac.corp.fortinet.com → ms-login.fortinet.com when SSO expired)
305
312
  // means the user needs to re-authenticate.
306
- let onExpected = false;
307
- try { onExpected = !failedNetwork && new URL(landed).hostname.toLowerCase() === opts.host.toLowerCase(); } catch { /* bad url */ }
313
+ let hostnameMatch = false;
314
+ try { hostnameMatch = !failedNetwork && new URL(landed).hostname.toLowerCase() === opts.host.toLowerCase(); } catch { /* bad url */ }
315
+ // When auth_required_selector is set, the extension flips ok:false if the
316
+ // login form is on the page — so a same-host login screen no longer passes.
317
+ // Older extensions / no selector → r.ok is undefined and we fall back to
318
+ // the host-only check.
319
+ const loginFormShown = opts.auth_required_selector ? r.ok === false : false;
320
+ const onExpected = hostnameMatch && !loginFormShown;
308
321
  return {
309
322
  ok: onExpected,
310
323
  message: onExpected ? `Session active · ${landed}` : undefined,
@@ -312,7 +325,9 @@ export async function runBrowserUrlProbe(opts: {
312
325
  ? undefined
313
326
  : (failedNetwork
314
327
  ? `Network unreachable — ${landed || '(no url)'}. VPN / hostname / firewall?`
315
- : `Not signed in — redirected to ${landed}`),
328
+ : loginFormShown
329
+ ? `Not signed in — login page shown at ${landed}`
330
+ : `Not signed in — redirected to ${landed}`),
316
331
  url: landed,
317
332
  duration_ms: Date.now() - t0,
318
333
  };
@@ -1,8 +1,20 @@
1
1
  # Schedules
2
2
 
3
- **Schedule = a pipeline + input + trigger.** Forge runs the named pipeline with
4
- your input whenever the trigger fires. The pipeline is the only execution unit;
5
- Schedule just decides *when* to fire.
3
+ **Schedule = a body + trigger.** The trigger decides *when* to fire; the body
4
+ decides *what* runs. There are two body kinds pick whichever fits:
5
+
6
+ - **Prompt** (simplest, most common) — free-text instructions run as a one-shot
7
+ Claude task on each fire. **The task has the full connector toolset
8
+ (Teams / Mantis / GitLab / etc.) plus any skills you attach** — so "every hour
9
+ check the FortiNAC Teams channel and message me anything new" is a single
10
+ prompt schedule. You do **not** need a pipeline for this. The prompt text is
11
+ saved to `prompts/<slug>.yaml` and the schedule row references it by name.
12
+ - **Pipeline** — fires an existing pipeline (`flows/<name>.yaml`) with input.
13
+ Use when you need a structured multi-node DAG, `for_each:` iteration, or
14
+ per-node project/worktree control.
15
+
16
+ > Don't build a throwaway pipeline just to run a prompt on a timer — use prompt
17
+ > mode. The chat `create_schedule` tool accepts either `prompt` or `workflow`.
6
18
 
7
19
  **Note:** Jobs (the older subsystem) is deprecated — its UI is hidden and the
8
20
  scheduler no longer ticks. Any existing rows in the `jobs` table are inert.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aion0/forge",
3
- "version": "0.10.64",
3
+ "version": "0.10.66",
4
4
  "description": "Unified AI workflow platform — multi-model task orchestration, persistent sessions, web terminal, remote access",
5
5
  "type": "module",
6
6
  "scripts": {
package/publish.sh CHANGED
@@ -9,11 +9,18 @@
9
9
 
10
10
  set -e
11
11
 
12
- # Preflight: fail BEFORE any version bump / commit / tag / push if gh is
13
- # not authenticated. Otherwise the release step 401s after everything else
14
- # already happened, leaving a tag pushed without a GitHub Release.
12
+ # Preflight: fail BEFORE any version bump / commit / tag / push if gh can't
13
+ # actually talk to GitHub. We hit a real endpoint (gh api user) instead of
14
+ # `gh auth status` status only checks a token is STORED, not that it still
15
+ # works; a stale/revoked keyring token passes status but 401s on every call.
16
+ GH_LOGIN_HINT="gh auth login --hostname github.com --web --git-protocol ssh"
15
17
  if command -v gh &> /dev/null; then
16
- gh auth status &> /dev/null || { echo "✗ gh is not authenticated — run 'gh auth login' first (the GitHub Release step would 401)."; exit 1; }
18
+ gh api user &> /dev/null || {
19
+ echo "✗ gh can't authenticate to GitHub (token missing/expired/revoked)."
20
+ echo " Re-auth, then re-run ./publish.sh:"
21
+ echo " $GH_LOGIN_HINT"
22
+ exit 1
23
+ }
17
24
  fi
18
25
 
19
26
  VERSION_ARG=${1:-patch}
@@ -144,8 +151,18 @@ git push origin "v$NEW_VERSION"
144
151
  if command -v gh &> /dev/null; then
145
152
  echo ""
146
153
  echo "Creating GitHub Release..."
147
- gh release create "v$NEW_VERSION" --title "v$NEW_VERSION" --notes-file "$RELEASE_NOTES_FILE"
148
- echo "✓ GitHub Release created: https://github.com/aiwatching/forge/releases/tag/v$NEW_VERSION"
154
+ if gh release create "v$NEW_VERSION" --title "v$NEW_VERSION" --notes-file "$RELEASE_NOTES_FILE"; then
155
+ echo "✓ GitHub Release created: https://github.com/aiwatching/forge/releases/tag/v$NEW_VERSION"
156
+ else
157
+ # The tag is already pushed at this point — re-auth and just create the
158
+ # release, no need to re-run the whole publish. A stale keyring token
159
+ # 401s here even though `gh auth status` claims you're logged in.
160
+ echo ""
161
+ echo "✗ GitHub Release failed (usually a stale gh token — 401). The tag v$NEW_VERSION is already pushed."
162
+ echo " Fix: re-auth, then create just the release:"
163
+ echo " $GH_LOGIN_HINT"
164
+ echo " gh release create v$NEW_VERSION --title v$NEW_VERSION --notes-file $RELEASE_NOTES_FILE"
165
+ fi
149
166
  else
150
167
  echo ""
151
168
  echo "gh CLI not found. Create release manually:"