diff --git a/docs/tools/xsearch.md b/docs/tools/xsearch.md index 78f38d5..371c40e 100644 --- a/docs/tools/xsearch.md +++ b/docs/tools/xsearch.md @@ -1,4 +1,4 @@ -# X / Twitter ツール(XSearch / XUserPosts / XPostDetail / XFetchCardMedia) +# X / Twitter ツール(XSearch / XUserPosts / XPostDetail / XTimeline / XFetchCardMedia) twitter-cli を内部で呼び出して X (旧 Twitter) のデータを取得する read-only ツール群。 @@ -37,6 +37,23 @@ XUserPosts({ }) ``` +## XTimeline — ホームタイムライン(ログイン中アカウント) + +```js +XTimeline({ + tab: "for_you", // for_you (デフォルト) / following + limit: 20, // デフォルト 20, 最大 50 + full_text: true, + compact: false, + output_path: "x/timeline.txt" // 任意 +}) +``` + +設定済みの `auth_token` / `ct0` cookie に紐づくアカウントの**ホームタイムライン**を返す。 +`tab: "for_you"`(おすすめ)と `tab: "following"`(フォロー中)を切り替えられる。 +内部的には `twitter feed [-t following]` を呼ぶ。検索ではなく「ログイン中ユーザーが +普段見るフィード」を取得したいときに使う。出力・メディア DL の扱いは XSearch と共通。 + ## XPostDetail — 投稿の詳細+リプライ ```js diff --git a/pieces/chat.yaml b/pieces/chat.yaml index 3272414..0e7eb22 100644 --- a/pieces/chat.yaml +++ b/pieces/chat.yaml @@ -66,7 +66,7 @@ movements: - `result` がそのままユーザーに表示される最終出力。途中のメモや作業ログは入れない - **ユーザー確認が必要**: `complete({status: "needs_user_input", missing_info: "確認したい内容", why_no_default: "デフォルトで進められない理由"})` - **技術的失敗で打ち切り**: `complete({status: "aborted", abort_reason: "失敗の理由"})` - allowed_tools: [Read, Write, Edit, Glob, Grep, WebSearch, WebFetch, DownloadFile, ReadImage, AnnotateImage, ReadPdf, PdfToImages, ReadExcel, ReadDocx, ReadPPTX, SQLite, Bash, XSearch, XUserPosts, XPostDetail, XFetchCardMedia, BrowseWeb, SearchPlaces, GetDirections, ReverseGeocode, GetYouTubeTranscript, SearchYouTube, SearchAmazon, TranscribeAudio, ListPieces, GetPiece, CreatePiece, UpdatePiece, SearchKnowledge, ListNamespaces, ListDocuments, SearchNotes, ReadNote, WriteNote, SearchMicrosoftLearn, FetchMicrosoftLearn, SearchMicrosoftLearnCache, RefreshMicrosoftLearnCache, ReadToolDoc, UpdateDashboardWidget, 'mcp__*'] + allowed_tools: [Read, Write, Edit, Glob, Grep, WebSearch, WebFetch, DownloadFile, ReadImage, AnnotateImage, ReadPdf, PdfToImages, ReadExcel, ReadDocx, ReadPPTX, SQLite, Bash, XSearch, XUserPosts, XPostDetail, XTimeline, XFetchCardMedia, BrowseWeb, SearchPlaces, GetDirections, ReverseGeocode, GetYouTubeTranscript, SearchYouTube, SearchAmazon, TranscribeAudio, ListPieces, GetPiece, CreatePiece, UpdatePiece, SearchKnowledge, ListNamespaces, ListDocuments, SearchNotes, ReadNote, WriteNote, SearchMicrosoftLearn, FetchMicrosoftLearn, SearchMicrosoftLearnCache, RefreshMicrosoftLearnCache, ReadToolDoc, UpdateDashboardWidget, 'mcp__*'] # default_next is the engine-internal fallback for context overflow / ASK # limit reached / SpawnSubTask unavailable. It is NOT exposed to the LLM. default_next: COMPLETE diff --git a/pieces/research-sub.yaml b/pieces/research-sub.yaml index dbbbccd..bb9aa31 100644 --- a/pieces/research-sub.yaml +++ b/pieces/research-sub.yaml @@ -45,7 +45,7 @@ movements: - **追加調査のため同じ dig を続行**: `transition({next_step: "dig"})` - **対象が曖昧で確認が必要**: `complete({status: "needs_user_input", missing_info: "...", why_no_default: "..."})` - **技術的失敗で打ち切り**: `complete({status: "aborted", abort_reason: "..."})` - allowed_tools: [Read, Write, Bash, Glob, Grep, WebSearch, WebFetch, BrowseWeb, DownloadFile, ReadImage, AnnotateImage, ReadPdf, PdfToImages, BatchReviewTextWithLLM, MergeReviewedResults, SearchPlaces, GetDirections, ReverseGeocode, GetYouTubeTranscript, SearchYouTube, SearchAmazon, TranscribeAudio, SearchKnowledge, ListNamespaces, ListDocuments, XSearch, XUserPosts, XPostDetail, XFetchCardMedia, SearchNotes, ReadNote, WriteNote, SearchMicrosoftLearn, FetchMicrosoftLearn, SearchMicrosoftLearnCache, RefreshMicrosoftLearnCache, 'mcp__*'] + allowed_tools: [Read, Write, Bash, Glob, Grep, WebSearch, WebFetch, BrowseWeb, DownloadFile, ReadImage, AnnotateImage, ReadPdf, PdfToImages, BatchReviewTextWithLLM, MergeReviewedResults, SearchPlaces, GetDirections, ReverseGeocode, GetYouTubeTranscript, SearchYouTube, SearchAmazon, TranscribeAudio, SearchKnowledge, ListNamespaces, ListDocuments, XSearch, XUserPosts, XPostDetail, XTimeline, XFetchCardMedia, SearchNotes, ReadNote, WriteNote, SearchMicrosoftLearn, FetchMicrosoftLearn, SearchMicrosoftLearnCache, RefreshMicrosoftLearnCache, 'mcp__*'] default_next: analyze rules: - condition: output/ に情報を書き出した @@ -72,7 +72,7 @@ movements: output/images/ に画像がある場合は、必ずレポートの該当箇所に埋め込む: `![説明](./images/ファイル名.png)` - allowed_tools: [Read, Write, Bash, Glob, Grep, WebSearch, WebFetch, BrowseWeb, DownloadFile, ReadImage, AnnotateImage, ReadPdf, PdfToImages, BatchReviewTextWithLLM, MergeReviewedResults, SearchPlaces, GetDirections, ReverseGeocode, GetYouTubeTranscript, SearchYouTube, SearchAmazon, TranscribeAudio, SearchKnowledge, ListNamespaces, ListDocuments, XSearch, XUserPosts, XPostDetail, XFetchCardMedia, SearchNotes, ReadNote, WriteNote, SearchMicrosoftLearn, FetchMicrosoftLearn, SearchMicrosoftLearnCache, RefreshMicrosoftLearnCache, 'mcp__*'] + allowed_tools: [Read, Write, Bash, Glob, Grep, WebSearch, WebFetch, BrowseWeb, DownloadFile, ReadImage, AnnotateImage, ReadPdf, PdfToImages, BatchReviewTextWithLLM, MergeReviewedResults, SearchPlaces, GetDirections, ReverseGeocode, GetYouTubeTranscript, SearchYouTube, SearchAmazon, TranscribeAudio, SearchKnowledge, ListNamespaces, ListDocuments, XSearch, XUserPosts, XPostDetail, XTimeline, XFetchCardMedia, SearchNotes, ReadNote, WriteNote, SearchMicrosoftLearn, FetchMicrosoftLearn, SearchMicrosoftLearnCache, RefreshMicrosoftLearnCache, 'mcp__*'] default_next: verify rules: - condition: output/ にレポートを書き出した diff --git a/pieces/research.yaml b/pieces/research.yaml index 7daf8d9..74cdb97 100644 --- a/pieces/research.yaml +++ b/pieces/research.yaml @@ -113,7 +113,7 @@ movements: - **追加調査のため同じ dig を続行**: `transition({next_step: "dig"})` - **対象が曖昧で確認が必要**: `complete({status: "needs_user_input", missing_info: "...", why_no_default: "..."})` - **技術的失敗で打ち切り**: `complete({status: "aborted", abort_reason: "..."})` - allowed_tools: [Read, Write, Bash, Glob, Grep, WebSearch, WebFetch, BrowseWeb, DownloadFile, ReadImage, AnnotateImage, ReadPdf, PdfToImages, BatchReviewTextWithLLM, MergeReviewedResults, SearchPlaces, GetDirections, ReverseGeocode, GetYouTubeTranscript, SearchYouTube, SearchAmazon, TranscribeAudio, SearchKnowledge, ListNamespaces, ListDocuments, XSearch, XUserPosts, XPostDetail, XFetchCardMedia, SearchNotes, ReadNote, WriteNote, SearchMicrosoftLearn, FetchMicrosoftLearn, SearchMicrosoftLearnCache, RefreshMicrosoftLearnCache, 'mcp__*'] + allowed_tools: [Read, Write, Bash, Glob, Grep, WebSearch, WebFetch, BrowseWeb, DownloadFile, ReadImage, AnnotateImage, ReadPdf, PdfToImages, BatchReviewTextWithLLM, MergeReviewedResults, SearchPlaces, GetDirections, ReverseGeocode, GetYouTubeTranscript, SearchYouTube, SearchAmazon, TranscribeAudio, SearchKnowledge, ListNamespaces, ListDocuments, XSearch, XUserPosts, XPostDetail, XTimeline, XFetchCardMedia, SearchNotes, ReadNote, WriteNote, SearchMicrosoftLearn, FetchMicrosoftLearn, SearchMicrosoftLearnCache, RefreshMicrosoftLearnCache, 'mcp__*'] default_next: analyze rules: - condition: 2つ以上の独立した調査テーマがあり、並列分解が効率的と判断した @@ -146,7 +146,7 @@ movements: 画像があるのにテキストだけのレポートにしないこと。 レポート作成中に追加で必要な図・グラフを見つけた場合も DownloadFile で収集して埋め込む。 - allowed_tools: [Read, Write, Bash, Glob, Grep, WebSearch, WebFetch, BrowseWeb, DownloadFile, ReadImage, AnnotateImage, ReadPdf, PdfToImages, BatchReviewTextWithLLM, MergeReviewedResults, SearchPlaces, GetDirections, ReverseGeocode, GetYouTubeTranscript, SearchYouTube, SearchAmazon, TranscribeAudio, SearchKnowledge, ListNamespaces, ListDocuments, XSearch, XUserPosts, XPostDetail, XFetchCardMedia, SearchNotes, ReadNote, WriteNote, SearchMicrosoftLearn, FetchMicrosoftLearn, SearchMicrosoftLearnCache, RefreshMicrosoftLearnCache, 'mcp__*'] + allowed_tools: [Read, Write, Bash, Glob, Grep, WebSearch, WebFetch, BrowseWeb, DownloadFile, ReadImage, AnnotateImage, ReadPdf, PdfToImages, BatchReviewTextWithLLM, MergeReviewedResults, SearchPlaces, GetDirections, ReverseGeocode, GetYouTubeTranscript, SearchYouTube, SearchAmazon, TranscribeAudio, SearchKnowledge, ListNamespaces, ListDocuments, XSearch, XUserPosts, XPostDetail, XTimeline, XFetchCardMedia, SearchNotes, ReadNote, WriteNote, SearchMicrosoftLearn, FetchMicrosoftLearn, SearchMicrosoftLearnCache, RefreshMicrosoftLearnCache, 'mcp__*'] default_next: verify rules: - condition: output/ にレポートを書き出した diff --git a/pieces/sns-research.yaml b/pieces/sns-research.yaml index 121921f..ec5772f 100644 --- a/pieces/sns-research.yaml +++ b/pieces/sns-research.yaml @@ -28,6 +28,7 @@ movements: - キーワードで広く拾う → XSearch - 特定アカウントの発言を追う → XUserPosts - 議論の流れ・リプライツリーまで欲しい → XPostDetail + - ログイン中アカウントのホームタイムライン(おすすめ / フォロー中)を見る → XTimeline ### Reddit BrowseWeb で必ず **old.reddit.com** を使う(軽量でテキスト抽出しやすい)。 @@ -56,7 +57,7 @@ movements: - **追加収集のため同じ gather を続行**: `transition({next_step: "gather"})` - **対象が曖昧で確認が必要**: `complete({status: "needs_user_input", missing_info: "...", why_no_default: "..."})` - **技術的失敗で打ち切り**: `complete({status: "aborted", abort_reason: "..."})` - allowed_tools: [XSearch, XUserPosts, XPostDetail, XFetchCardMedia, BrowseWeb, WebFetch, WebSearch, Read, Write, Edit, Glob, Grep, DownloadFile, SearchKnowledge, ListNamespaces, ListDocuments, SearchNotes, ReadNote, 'mcp__*'] + allowed_tools: [XSearch, XUserPosts, XPostDetail, XTimeline, XFetchCardMedia, BrowseWeb, WebFetch, WebSearch, Read, Write, Edit, Glob, Grep, DownloadFile, SearchKnowledge, ListNamespaces, ListDocuments, SearchNotes, ReadNote, 'mcp__*'] default_next: analyze rules: - condition: 追加収集が必要(別のSNS、追加クエリ等) diff --git a/pieces/x-ai-digest.yaml b/pieces/x-ai-digest.yaml index 858c76f..602caeb 100644 --- a/pieces/x-ai-digest.yaml +++ b/pieces/x-ai-digest.yaml @@ -61,6 +61,7 @@ movements: - XSearch - XUserPosts - XPostDetail + - XTimeline - XFetchCardMedia - BrowseWeb - WebFetch diff --git a/src/engine/tools/docs.ts b/src/engine/tools/docs.ts index a465088..2e7810d 100644 --- a/src/engine/tools/docs.ts +++ b/src/engine/tools/docs.ts @@ -41,6 +41,7 @@ const TOOL_DOC_ALIASES: Record = { xuserposts: 'xsearch', xpostdetail: 'xsearch', xfetchcardmedia: 'xsearch', + xtimeline: 'xsearch', // youtube.ts をまとめる searchyoutube: 'getyoutubetranscript', // maps.ts をまとめる diff --git a/src/engine/tools/raw-save.ts b/src/engine/tools/raw-save.ts index 9bf766c..a3783da 100644 --- a/src/engine/tools/raw-save.ts +++ b/src/engine/tools/raw-save.ts @@ -9,6 +9,7 @@ export const RAW_SAVE_TOOLS = new Set([ 'XSearch', 'XUserPosts', 'XPostDetail', + 'XTimeline', 'BrowseWeb', 'GetYouTubeTranscript', 'SearchYouTube', diff --git a/src/engine/tools/ssh-console.test.ts b/src/engine/tools/ssh-console.test.ts index 0978a1e..7a41b2b 100644 --- a/src/engine/tools/ssh-console.test.ts +++ b/src/engine/tools/ssh-console.test.ts @@ -126,7 +126,7 @@ function mkStubSubsystem() { setWindow: vi.fn(), on: vi.fn(), }; - const client = { end: vi.fn() }; + const client = { end: vi.fn(), on: vi.fn() }; const openShellChannel = vi.fn().mockResolvedValue({ channel, client, diff --git a/src/engine/tools/ssh-console.ts b/src/engine/tools/ssh-console.ts index 7850e4e..140401a 100644 --- a/src/engine/tools/ssh-console.ts +++ b/src/engine/tools/ssh-console.ts @@ -246,6 +246,7 @@ async function ensureSessionInternal( // Open the channel. On failure clear the PEM and bail. let channel: import('ssh2').ClientChannel; + let client: import('ssh2').Client; let hostFingerprint: string; try { const shellResult = await sub.openShellChannel({ @@ -266,6 +267,7 @@ async function ensureSessionInternal( timeoutMs: sub.config.callTimeoutSeconds * 1000, }); channel = shellResult.channel; + client = shellResult.client; hostFingerprint = shellResult.hostFingerprint; } catch (e) { clearBuffer(pemBuf); @@ -292,8 +294,10 @@ async function ensureSessionInternal( return err(`SshConsoleEnsure: failed to open shell channel: ${(e as Error).message}`); } - // Build the session and register it. From here on the channel + PEM - // belong to the session; we don't clear them on the happy path. + // Build the session and register it. From here on the channel + client + // + PEM belong to the session; we don't clear them on the happy path. + // The session ends the client (and thus releases the PEM-bound + // connection) when it closes. const session = new ConsoleSession({ localTaskId, connectionId, @@ -303,6 +307,7 @@ async function ensureSessionInternal( rows, scrollbackCap: sub.config.console.scrollbackBytes, channel, + client, auditRepo: sub.auditRepo, }); sub.sessionRegistry.register(session); diff --git a/src/engine/tools/x.ts b/src/engine/tools/x.ts index 3cdf389..2fbcec6 100644 --- a/src/engine/tools/x.ts +++ b/src/engine/tools/x.ts @@ -129,11 +129,30 @@ const XFETCHCARDMEDIA_DEF: ToolDef = { }, }; +const XTIMELINE_DEF: ToolDef = { + type: 'function', + function: { + name: 'XTimeline', + description: 'ログイン中アカウントのホームタイムライン(For You / フォロー中)を取得する(twitter-cli 経由、認証 Cookie 設定が必要)。詳細は ReadToolDoc({ name: "XTimeline" })。', + parameters: { + type: 'object', + properties: { + tab: { type: 'string', enum: ['for_you', 'following'], description: 'for_you (デフォルト) / following' }, + limit: { type: 'number', description: '件数 (デフォルト: 20, 最大: 50)' }, + full_text: { type: 'boolean', description: '長文の省略を避ける' }, + compact: { type: 'boolean', description: 'token 節約向けの compact 出力' }, + output_path: { type: 'string', description: '任意: output/x/ 配下に保存する相対パス' }, + }, + }, + }, +}; + export const TOOL_DEFS: Record = { XSearch: XSEARCH_DEF, XUserPosts: XUSERPOSTS_DEF, XPostDetail: XPOSTDETAIL_DEF, XFetchCardMedia: XFETCHCARDMEDIA_DEF, + XTimeline: XTIMELINE_DEF, }; type XHistoryRecord = { @@ -608,6 +627,36 @@ async function executeXUserPosts(input: Record, ctx: ToolContex return runTwitterCli('XUserPosts', args, ctx, outputPath); } +async function executeXTimeline(input: Record, ctx: ToolContext): Promise { + const tab = String(input['tab'] ?? 'for_you'); + const following = tab === 'following'; + const args = ['feed', '--max', String(clampLimit(input['limit'], 20)), '--yaml']; + if (following) args.push('-t', 'following'); + maybePushFlag(args, input['full_text'], '--full-text'); + maybePushFlag(args, input['compact'], '--compact'); + let outputPath: string | null; + try { + outputPath = resolveOptionalOutputPath(ctx, input['output_path']); + } catch (err) { + return { output: `XTimeline error: ${(err as Error).message}`, isError: true }; + } + const result = await runTwitterCli('XTimeline', args, ctx, outputPath); + if (result.isError) return result; + + const posts = parseXPostsFromYaml(result.output); + if (posts.length > 0) { + const refId = `xposts-${Date.now()}`; + const structuredBlocks: StructuredBlock[] = [{ + refId, + type: 'x_posts', + title: following ? 'X タイムライン(フォロー中)' : 'X タイムライン(For You)', + data: { query: following ? 'following' : 'for_you', posts }, + }]; + return { output: `${result.output}\n\n[[embed:${refId}]]`, isError: false, structuredBlocks }; + } + return result; +} + async function executeXPostDetail(input: Record, ctx: ToolContext): Promise { const tweet = String(input['tweet'] ?? '').trim(); if (!tweet) return { output: 'XPostDetail error: tweet is required', isError: true }; @@ -862,6 +911,8 @@ export async function executeTool( return executeXSearch(input, ctx); case 'XUserPosts': return executeXUserPosts(input, ctx); + case 'XTimeline': + return executeXTimeline(input, ctx); case 'XPostDetail': return executeXPostDetail(input, ctx); case 'XFetchCardMedia': diff --git a/src/metrics/tool-name-allowlist.ts b/src/metrics/tool-name-allowlist.ts index c25fb58..f4c2756 100644 --- a/src/metrics/tool-name-allowlist.ts +++ b/src/metrics/tool-name-allowlist.ts @@ -57,7 +57,7 @@ const BUILTIN_TOOL_NAMES_LIST: ReadonlyArray = [ // orchestration.ts 'SpawnSubTask', // x.ts - 'XPostDetail', 'XSearch', 'XUserPosts', + 'XPostDetail', 'XSearch', 'XTimeline', 'XUserPosts', // maps.ts 'GetDirections', 'ReverseGeocode', 'SearchPlaces', // youtube.ts diff --git a/src/ssh/console-session.test.ts b/src/ssh/console-session.test.ts index c3be0ff..1c5f8bb 100644 --- a/src/ssh/console-session.test.ts +++ b/src/ssh/console-session.test.ts @@ -13,6 +13,11 @@ class StubChannel extends EventEmitter { } } +class StubClient extends EventEmitter { + ended = false; + end(): void { this.ended = true; this.emit('close'); } +} + function mkAudit() { return { beginAndComplete: vi.fn(), @@ -21,7 +26,7 @@ function mkAudit() { }; } -function mkSession(channel: StubChannel) { +function mkSession(channel: StubChannel, client: StubClient = new StubClient()) { const audit = mkAudit(); const session = new ConsoleSession({ localTaskId: 't1', @@ -32,9 +37,10 @@ function mkSession(channel: StubChannel) { rows: 24, scrollbackCap: 1024, channel: channel as any, + client: client as any, auditRepo: audit as any, }); - return { session, audit }; + return { session, audit, client }; } describe('ConsoleSession', () => { @@ -131,18 +137,43 @@ describe('ConsoleSession', () => { expect(ch.windowChanges).toEqual([{ rows: 40, cols: 100 }]); }); - it('close() is idempotent and records audit', async () => { + it('close() is idempotent, ends the client, and records audit', async () => { const ch = new StubChannel(); - const { session, audit } = mkSession(ch); + const { session, audit, client } = mkSession(ch); await session.close('idle_timeout'); await session.close('idle_timeout'); expect(ch.ended).toBe(true); + expect(client.ended).toBe(true); expect(audit.beginAndComplete).toHaveBeenCalledTimes(1); const call = audit.beginAndComplete.mock.calls[0]![0]; expect(call.action).toBe('ssh.console.close'); expect(call.detail.reason).toBe('idle_timeout'); }); + it('client "error" (ECONNRESET) tears the session down without throwing', async () => { + const ch = new StubChannel(); + const { session, audit, client } = mkSession(ch); + // Regression for #407: an unhandled ssh2 Client 'error' event crashes + // the whole Node process. ConsoleSession must own a listener so a + // dropped transport closes only this session. + expect(() => client.emit('error', new Error('read ECONNRESET'))).not.toThrow(); + // close() runs on the next tick via the catch-less promise chain. + await Promise.resolve(); + expect(session.isClosed).toBe(true); + expect(audit.beginAndComplete).toHaveBeenCalledTimes(1); + expect(audit.beginAndComplete.mock.calls[0]![0].detail.reason).toBe('host_disconnect'); + }); + + it('client "close" tears the session down once', async () => { + const ch = new StubChannel(); + const { session, audit, client } = mkSession(ch); + client.emit('close'); + await Promise.resolve(); + expect(session.isClosed).toBe(true); + expect(audit.beginAndComplete).toHaveBeenCalledTimes(1); + expect(audit.beginAndComplete.mock.calls[0]![0].detail.reason).toBe('host_disconnect'); + }); + it('scrollback caps at scrollbackCap', () => { const ch = new StubChannel(); const { session } = mkSession(ch); diff --git a/src/ssh/console-session.ts b/src/ssh/console-session.ts index 69c20f0..c09ef95 100644 --- a/src/ssh/console-session.ts +++ b/src/ssh/console-session.ts @@ -1,6 +1,6 @@ import { createRequire } from 'node:module'; import type { Terminal as HeadlessTerminalType } from '@xterm/headless'; -import type { ClientChannel } from 'ssh2'; +import type { Client, ClientChannel } from 'ssh2'; import { ByteRingBuffer } from './ring-buffer.js'; /** Where an input chunk came from — drives audit `source` + back-pressure label. */ export type InputSource = 'human' | 'ai'; @@ -26,6 +26,13 @@ export interface ConsoleSessionArgs { rows: number; scrollbackCap: number; channel: ClientChannel; + /** + * The ssh2 Client backing `channel`. The session owns it: it ends the + * client on close() (so the underlying connection/socket doesn't leak) + * and reacts to client-level 'error'/'close' so a dropped transport tears + * the session down instead of leaving a half-dead session registered. + */ + client: Client; auditRepo: SshAuditRepo; } @@ -108,6 +115,7 @@ export class ConsoleSession { rows: number; private readonly channel: ClientChannel; + private readonly client: Client; private readonly headless: HeadlessTerminal; private readonly scrollback: ByteRingBuffer; private readonly auditRepo: SshAuditRepo; @@ -131,6 +139,7 @@ export class ConsoleSession { this.cols = args.cols; this.rows = args.rows; this.channel = args.channel; + this.client = args.client; this.scrollback = new ByteRingBuffer(args.scrollbackCap); this.auditRepo = args.auditRepo; this.headless = new HeadlessTerminal({ @@ -144,13 +153,29 @@ export class ConsoleSession { }); this.channel.on('data', (data: Buffer) => this.handleOutput(data)); - this.channel.on('close', () => { - if (!this.closing) { - this.close('host_disconnect').catch((e) => - logger.warn(`[console-session] close error: ${(e as Error).message}`), - ); - } + this.channel.on('close', () => this.onTransportDown()); + + // The Client (connection) can fail independently of the channel: + // ssh2 re-emits transport-level socket errors (ECONNRESET on idle + // timeout, network drop) as an 'error' on the Client. Without a + // listener Node treats an 'error' event as fatal and kills the whole + // process — this handler keeps a dropped connection to a single + // session-scoped teardown. 'close' covers a graceful peer disconnect. + this.client.on('error', (e: Error) => { + logger.warn( + `[console-session] client error task=${this.localTaskId}: ${(e as Error).message}`, + ); + this.onTransportDown(); }); + this.client.on('close', () => this.onTransportDown()); + } + + /** Tear the session down once when the underlying SSH transport drops. */ + private onTransportDown(): void { + if (this.closing) return; + this.close('host_disconnect').catch((e) => + logger.warn(`[console-session] close error: ${(e as Error).message}`), + ); } get lastActivityAt(): number { @@ -275,6 +300,15 @@ export class ConsoleSession { } catch { /* already gone */ } + try { + // End the owning Client too — channel.end() only closes the shell + // channel, the TCP connection stays up until the client is ended. + // Guarded by `closing` above so the resulting 'close' event is a + // no-op in onTransportDown. + this.client.end(); + } catch { + /* already gone */ + } try { this.headless.dispose(); } catch { diff --git a/src/ssh/session.ts b/src/ssh/session.ts index 3239afc..aa25887 100644 --- a/src/ssh/session.ts +++ b/src/ssh/session.ts @@ -219,15 +219,42 @@ async function openClient( const settle = (err: Error | null) => { if (settled) return; settled = true; - client.removeAllListeners('ready'); - client.removeAllListeners('error'); - client.removeAllListeners('close'); - if (err) reject(err); - else resolve(client); + // Detach the handshake-only listeners with the exact references + // (removeAllListeners would also strip the permanent error handler). + client.removeListener('ready', onReady); + client.removeListener('close', onClose); + if (err) { + // Failed handshake: the caller destroys the socket and never uses + // this client, so drop the error handler too. + client.removeListener('error', onError); + reject(err); + } else { + // Success: keep `onError` attached. The client can outlive this + // call (an interactive console keeps it alive for the whole + // session), and ssh2 re-emits transport-level failures + // (ECONNRESET on idle timeout, network drop) as an 'error' event + // on the Client. A Node 'error' event with no listener is fatal to + // the entire process — this permanent handler is the safety net + // that turns a dropped connection into a log line instead of a + // crash. The live-client owner (ConsoleSession / exec finally) + // drives the actual teardown. + resolve(client); + } }; - client.once('ready', () => settle(null)); - client.once('error', (err: Error) => settle(err)); - client.once('close', () => settle(new Error('connection_closed_during_handshake'))); + const onReady = () => settle(null); + const onClose = () => settle(new Error('connection_closed_during_handshake')); + const onError = (err: Error) => { + if (!settled) { + settle(err); + } else { + logger.warn( + `[ssh:session] client error after handshake conn=${connection.id}: ${sanitizeError(err).message}`, + ); + } + }; + client.on('error', onError); + client.once('ready', onReady); + client.once('close', onClose); try { client.connect(config); } catch (e) { diff --git a/ui/src/components/files/FilePreview.tsx b/ui/src/components/files/FilePreview.tsx index facec77..a6f3161 100644 --- a/ui/src/components/files/FilePreview.tsx +++ b/ui/src/components/files/FilePreview.tsx @@ -702,7 +702,7 @@ export function FilePreview({ name, content, imageSrc, markdownImageBaseUrl, onC src={imageSrc} sandbox="allow-scripts allow-same-origin" className="w-full rounded-lg border-0" - style={{ height: '72vh' }} + style={{ height: '80vh' }} title={name} /> ); @@ -713,13 +713,13 @@ export function FilePreview({ name, content, imageSrc, markdownImageBaseUrl, onC src={imageSrc} type="application/pdf" className="w-full rounded-lg" - style={{ height: '72vh' }} + style={{ height: '80vh' }} /> ); } return (
- {name} + {name}
); } @@ -729,8 +729,9 @@ export function FilePreview({ name, content, imageSrc, markdownImageBaseUrl, onC return
{currentContent.slice(0, 100000)}
; })(); - const isMarkdown = /\.(md|markdown)$/i.test(name); - const modalWidth = isMarkdown ? 'min(1400px, 96vw)' : 'min(1000px, 94vw)'; + // All preview types share the wide modal so HTML / PDF / images / tables get + // the same generous viewport as Markdown (previously non-MD was ~40% narrower). + const modalWidth = 'min(1400px, 96vw)'; return (