sync: update from private repo (7d64ee2)
Some checks failed
CI / build-and-test (push) Has been cancelled

This commit is contained in:
oss-sync 2026-06-05 05:42:11 +00:00
parent c526adddc2
commit 02c7dfdd83
16 changed files with 205 additions and 35 deletions

View File

@ -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

View File

@ -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

View File

@ -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/ にレポートを書き出した

View File

@ -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/ にレポートを書き出した

View File

@ -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、追加クエリ等

View File

@ -61,6 +61,7 @@ movements:
- XSearch
- XUserPosts
- XPostDetail
- XTimeline
- XFetchCardMedia
- BrowseWeb
- WebFetch

View File

@ -41,6 +41,7 @@ const TOOL_DOC_ALIASES: Record<string, string> = {
xuserposts: 'xsearch',
xpostdetail: 'xsearch',
xfetchcardmedia: 'xsearch',
xtimeline: 'xsearch',
// youtube.ts をまとめる
searchyoutube: 'getyoutubetranscript',
// maps.ts をまとめる

View File

@ -9,6 +9,7 @@ export const RAW_SAVE_TOOLS = new Set([
'XSearch',
'XUserPosts',
'XPostDetail',
'XTimeline',
'BrowseWeb',
'GetYouTubeTranscript',
'SearchYouTube',

View File

@ -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,

View File

@ -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);

View File

@ -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<string, ToolDef> = {
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<string, unknown>, ctx: ToolContex
return runTwitterCli('XUserPosts', args, ctx, outputPath);
}
async function executeXTimeline(input: Record<string, unknown>, ctx: ToolContext): Promise<ToolResult> {
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<string, unknown>, ctx: ToolContext): Promise<ToolResult> {
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':

View File

@ -57,7 +57,7 @@ const BUILTIN_TOOL_NAMES_LIST: ReadonlyArray<string> = [
// orchestration.ts
'SpawnSubTask',
// x.ts
'XPostDetail', 'XSearch', 'XUserPosts',
'XPostDetail', 'XSearch', 'XTimeline', 'XUserPosts',
// maps.ts
'GetDirections', 'ReverseGeocode', 'SearchPlaces',
// youtube.ts

View File

@ -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);

View File

@ -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,14 +153,30 @@ export class ConsoleSession {
});
this.channel.on('data', (data: Buffer) => this.handleOutput(data));
this.channel.on('close', () => {
if (!this.closing) {
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 {
return this._lastActivityAt;
@ -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 {

View File

@ -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) {

View File

@ -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 (
<div className="flex justify-center">
<img src={imageSrc} alt={name} className="max-w-full max-h-[72vh] object-contain rounded-lg" />
<img src={imageSrc} alt={name} className="max-w-full max-h-[80vh] object-contain rounded-lg" />
</div>
);
}
@ -729,8 +729,9 @@ export function FilePreview({ name, content, imageSrc, markdownImageBaseUrl, onC
return <pre className="text-xs whitespace-pre-wrap break-all">{currentContent.slice(0, 100000)}</pre>;
})();
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 (
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50 p-[env(safe-area-inset-top)_env(safe-area-inset-right)_env(safe-area-inset-bottom)_env(safe-area-inset-left)]" onClick={onClose}>