@canonmsg/codex-plugin 0.18.2 → 0.18.4

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/dist/host.d.ts CHANGED
@@ -1,2 +1,19 @@
1
1
  #!/usr/bin/env node
2
+ /** GPT reasoning-effort levels, applied next turn via model_reasoning_effort. */
3
+ export declare const CODEX_EFFORT_OPTIONS: readonly [{
4
+ readonly value: "minimal";
5
+ readonly label: "Minimal";
6
+ }, {
7
+ readonly value: "low";
8
+ readonly label: "Low";
9
+ }, {
10
+ readonly value: "medium";
11
+ readonly label: "Medium";
12
+ }, {
13
+ readonly value: "high";
14
+ readonly label: "High";
15
+ }, {
16
+ readonly value: "xhigh";
17
+ readonly label: "Extra high";
18
+ }];
2
19
  export declare function main(): Promise<void>;
package/dist/host.js CHANGED
@@ -5,6 +5,7 @@ import { spawnSync } from 'node:child_process';
5
5
  import { dirname } from 'node:path';
6
6
  import { parseArgs } from 'node:util';
7
7
  import { getCodexImagePath, materializeMessageMedia, materializeReplyContextMedia, sendMediaFileMessage, } from '@canonmsg/agent-sdk';
8
+ import { buildTurnArtifactContextLine, ensureTurnArtifactDir, flushTurnArtifacts, } from '@canonmsg/coding-agent-host';
8
9
  import { RUNTIME_NEW_SESSION_ACTION, RUNTIME_STOP_ACTION, RUNTIME_STOP_AND_DROP_ACTION, buildRuntimeCardOutcome, buildPlanApprovalRequest, resolveQuestionAllowOther, buildCanonTurnContextV2, buildConfiguredWorkspaceOptionsWithRoots, buildFirstPartyCodingRuntimeDescriptor, buildHydratedInboundContext, diffCanonMemberIds, buildPublicWorkspaceRoots, buildPublicWorkspaceOptions, buildRuntimePresentationPolicy, DEFAULT_FIRST_PARTY_RUNTIME_PRESENTATION, createConversationMetadataLoader, createRuntimeStatePublisher, createTypingStatusPublisher, EXECUTION_ENVIRONMENT_MODES, ExecutionEnvironmentError, CanonClient, CanonStream, DEFAULT_PARTICIPATION_HISTORY_FETCH_LIMIT, DEFAULT_RUNTIME_CAPABILITIES, FINAL_MESSAGE_HANDOFF_MS, getActiveProfileLock, initRTDBAuth, buildLocalRuntimeId, heartbeatLocalRuntimeEntry, loadRuntimeSessionState, markLocalRuntimeStopped, normalizeTurnMetadata, parseRuntimeCardV1, prepareConversationEnvironment, loadHostSessionConfig, releaseConversationEnvironment, resolveCanonAgent, CanonApiError, sendMessageWithRetry, sendMessageWithRetryChunked, shouldTriggerAgentTurn, saveRuntimeSessionState, buildBoundedTurnTrail, publishHostAgentRuntime, publishHostSessionSnapshots, renderCanonHostInboundContent, renderCanonTurnBriefPrompt, resolveHostWorkspaceCwd, upsertLocalRuntimeEntry, } from '@canonmsg/core';
9
10
  import { decideAutoReply, } from './inbound-policy.js';
10
11
  import { CodexConversationAdapter, } from './adapter.js';
@@ -16,7 +17,6 @@ import { detectCodexCliVersion } from './codex-cli-version.js';
16
17
  import { buildCodexModelGuardMessage, formatCodexTurnFailure, isRecoverableCodexThreadError, } from './error-format.js';
17
18
  import { startCodexStreamInBackground } from './host-lifecycle.js';
18
19
  import { createCodexControlPoller } from './control-channel.js';
19
- import { buildOutboxContextLine, ensureOutboxDir, flushOutbox, resolveOutboxDir, } from './outbox.js';
20
20
  import { runCli } from './cli-entry.js';
21
21
  import { collectMissedInboundMessages, STARTUP_RECOVERY_MAX_MESSAGES, STARTUP_RECOVERY_PAGE_SIZE, } from './startup-recovery.js';
22
22
  import { beginCommandBlock, claimCommandBlock, createCommandBlockTracker, } from './turn-activity.js';
@@ -69,11 +69,12 @@ let workspaceOptions = [];
69
69
  let workspaceRoots = [];
70
70
  let workspaceRootMetadata = [];
71
71
  /** GPT reasoning-effort levels, applied next turn via model_reasoning_effort. */
72
- const CODEX_EFFORT_OPTIONS = [
72
+ export const CODEX_EFFORT_OPTIONS = [
73
73
  { value: 'minimal', label: 'Minimal' },
74
74
  { value: 'low', label: 'Low' },
75
75
  { value: 'medium', label: 'Medium' },
76
76
  { value: 'high', label: 'High' },
77
+ { value: 'xhigh', label: 'Extra high' },
77
78
  ];
78
79
  const CODEX_EFFORT_VALUES = new Set(CODEX_EFFORT_OPTIONS.map((option) => option.value));
79
80
  function buildCodexRuntimeDescriptor(input) {
@@ -888,7 +889,6 @@ export async function main() {
888
889
  };
889
890
  sessions.set(conversationId, session);
890
891
  await controlPoller.baseline([conversationId]);
891
- ensureOutboxDir(sessionCwd).catch((error) => console.error(`[canon-codex] [${conversationId.slice(0, 8)}] Failed to create media outbox:`, error));
892
892
  console.error(`[canon-codex] [${conversationId.slice(0, 8)}] Environment → ${environment.mode} (${sessionCwd})`);
893
893
  writeState(session);
894
894
  writeTurn(session);
@@ -1288,7 +1288,6 @@ export async function main() {
1288
1288
  activeSelfContextId,
1289
1289
  provenance: hydrated.provenance,
1290
1290
  replyContext,
1291
- sessionContextLines: [buildOutboxContextLine(session.cwd)],
1292
1291
  message: input.message,
1293
1292
  });
1294
1293
  if (session.running && deliveryIntent === 'interrupt') {
@@ -1301,16 +1300,13 @@ export async function main() {
1301
1300
  }
1302
1301
  enqueuePrompt(session, prompt, deliveryIntent, false, input.message.id, shouldMarkAccepted, imagePaths, mediaAddDirs, planCommand.planMode);
1303
1302
  }
1304
- /**
1305
- * Turn-end media outbox flush (see ./outbox.ts for the convention). Never
1306
- * throws: upload failures are logged and the files stay in place for a
1307
- * later turn.
1308
- */
1309
- async function flushSessionOutbox(session) {
1303
+ async function routeSessionArtifacts(session, artifactDir) {
1304
+ if (!artifactDir)
1305
+ return;
1310
1306
  const logPrefix = `[canon-codex] [${session.conversationId.slice(0, 8)}]`;
1311
1307
  try {
1312
- const result = await flushOutbox({
1313
- outboxDir: resolveOutboxDir(session.cwd),
1308
+ const result = await flushTurnArtifacts({
1309
+ artifactDir,
1314
1310
  send: (file) => sendMediaFileMessage(client, session.conversationId, file.path, '', {
1315
1311
  ...(session.activeSelfContextId ? { selfContextId: session.activeSelfContextId } : {}),
1316
1312
  metadata: {
@@ -1324,20 +1320,20 @@ export async function main() {
1324
1320
  }),
1325
1321
  });
1326
1322
  for (const entry of result.sent) {
1327
- console.error(`${logPrefix} Sent outbox file ${entry.file.fileName} (${entry.messageId})`
1328
- + (entry.removeFailed ? ' — removal failed; it may resend next turn' : ''));
1323
+ console.error(`${logPrefix} Routed artifact ${entry.file.fileName} (${entry.messageId})`
1324
+ + (entry.removeFailed ? '; removal failed, so it may resend later' : ''));
1329
1325
  }
1330
1326
  for (const failure of result.failed) {
1331
- console.error(`${logPrefix} Outbox upload failed for ${failure.file.fileName}; left in place: ${failure.error}`);
1327
+ console.error(`${logPrefix} Artifact upload failed for ${failure.file.fileName}; left in place: ${failure.error}`);
1332
1328
  }
1333
1329
  for (const skipped of result.skipped) {
1334
- if (skipped.reason === 'too-large' || skipped.reason === 'file-cap') {
1335
- console.error(`${logPrefix} Outbox skipped ${skipped.fileName} (${skipped.reason}); left in place`);
1330
+ if (skipped.reason === 'too-large' || skipped.reason === 'file-cap' || skipped.reason === 'unsupported') {
1331
+ console.error(`${logPrefix} Artifact skipped ${skipped.fileName} (${skipped.reason})`);
1336
1332
  }
1337
1333
  }
1338
1334
  }
1339
1335
  catch (error) {
1340
- console.error(`${logPrefix} Outbox flush failed:`, error instanceof Error ? error.message : error);
1336
+ console.error(`${logPrefix} Artifact routing failed:`, error instanceof Error ? error.message : error);
1341
1337
  }
1342
1338
  }
1343
1339
  async function runNextTurn(session) {
@@ -1365,7 +1361,19 @@ export async function main() {
1365
1361
  // Status-only seed: 'thinking' renders as a working filament row on the
1366
1362
  // clients; text here would be bubbled as speech (v4 register rule).
1367
1363
  writeCodexStreaming(session, '', 'thinking');
1364
+ let artifactDir = null;
1365
+ let artifactsRouted = false;
1366
+ const routeArtifactsOnce = async () => {
1367
+ if (artifactsRouted)
1368
+ return;
1369
+ artifactsRouted = true;
1370
+ await routeSessionArtifacts(session, artifactDir);
1371
+ };
1368
1372
  try {
1373
+ const turnId = session.currentTurnId ?? randomUUID();
1374
+ session.currentTurnId = turnId;
1375
+ artifactDir = await ensureTurnArtifactDir(session.cwd, turnId);
1376
+ const turnPrompt = `${nextTurn.prompt}\n\n${buildTurnArtifactContextLine(artifactDir)}`;
1369
1377
  const modelGuard = buildCodexModelGuardMessage(session.state.model, codexCliStatus);
1370
1378
  if (modelGuard) {
1371
1379
  throw new ExecutionEnvironmentError(modelGuard, modelGuard);
@@ -1469,7 +1477,7 @@ export async function main() {
1469
1477
  clearStoredThreadId(runtimeId, agentId, session.conversationId, session.environment.baseCwd, session.environment.mode);
1470
1478
  session.adapter.clearThreadId();
1471
1479
  };
1472
- const runTurnOnce = () => session.adapter.runTurn(nextTurn.prompt, handleCodexEvent, logCodexLine, turnImagePaths, turnMediaAddDirs, {
1480
+ const runTurnOnce = () => session.adapter.runTurn(turnPrompt, handleCodexEvent, logCodexLine, turnImagePaths, turnMediaAddDirs, {
1473
1481
  planMode: nextTurn.planMode,
1474
1482
  onServerRequest: (request) => handleCodexServerRequest(session, request),
1475
1483
  });
@@ -1486,12 +1494,8 @@ export async function main() {
1486
1494
  if (result.threadId && !session.resetRequested) {
1487
1495
  saveStoredThreadId(runtimeId, agentId, session.conversationId, session.environment.baseCwd, result.threadId, session.environment.mode, session.policyFingerprint);
1488
1496
  }
1489
- // Turn-end outbox flush — media lands before the final text reply.
1490
- // Interrupted turns keep their files for the next completed turn.
1491
- if (!result.interrupted) {
1492
- await flushSessionOutbox(session);
1493
- }
1494
1497
  if (!result.interrupted && result.finalMessage && nextTurn.planMode) {
1498
+ await routeArtifactsOnce();
1495
1499
  const planApproval = buildPlanApprovalRequest(session.currentTurnId ?? randomUUID(), 'Plan ready for review.', {
1496
1500
  responseUserId: ownerId ?? undefined,
1497
1501
  title: 'Codex Plan',
@@ -1513,6 +1517,7 @@ export async function main() {
1513
1517
  if (isRecoverableCodexThreadError(result.errorText)) {
1514
1518
  clearStoredThread();
1515
1519
  }
1520
+ await routeArtifactsOnce();
1516
1521
  const turnTrail = buildFinalTurnTrail(session);
1517
1522
  await sendMessageWithRetryChunked(client, session.conversationId, result.finalMessage, {
1518
1523
  messageId: buildCodexMessageId(session, 'final'),
@@ -1530,6 +1535,7 @@ export async function main() {
1530
1535
  console.error(`[canon-codex] [${session.conversationId.slice(0, 8)}] Sent reply (${result.finalMessage.length} chars)`);
1531
1536
  }
1532
1537
  else if (!result.interrupted && result.exitCode && result.exitCode !== 0) {
1538
+ await routeArtifactsOnce();
1533
1539
  const userVisibleError = formatCodexTurnFailure(result.errorText);
1534
1540
  session.state.lastError = userVisibleError;
1535
1541
  writeState(session);
@@ -1552,6 +1558,7 @@ export async function main() {
1552
1558
  await handoffFinalMessage(session.conversationId);
1553
1559
  }
1554
1560
  else if (!result.interrupted) {
1561
+ await routeArtifactsOnce();
1555
1562
  await handoffFinalMessage(session.conversationId);
1556
1563
  }
1557
1564
  else if (result.interrupted) {
@@ -1571,6 +1578,7 @@ export async function main() {
1571
1578
  : `The Codex host failed during the turn: ${error instanceof Error ? error.message : String(error)}`;
1572
1579
  session.state.lastError = message;
1573
1580
  writeState(session);
1581
+ await routeArtifactsOnce();
1574
1582
  await sendMessageWithRetryChunked(client, session.conversationId, message, {
1575
1583
  messageId: buildCodexMessageId(session, 'failure'),
1576
1584
  ...(session.activeSelfContextId
@@ -1816,7 +1824,7 @@ export async function main() {
1816
1824
  {
1817
1825
  id: 'mediaOut',
1818
1826
  label: 'Media out',
1819
- value: 'Turn-end outbox',
1827
+ value: 'Explicit turn artifact directory',
1820
1828
  },
1821
1829
  ],
1822
1830
  execution: {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@canonmsg/codex-plugin",
3
- "version": "0.18.2",
3
+ "version": "0.18.4",
4
4
  "description": "Canon host integration for Codex CLI",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -21,7 +21,7 @@
21
21
  "scripts"
22
22
  ],
23
23
  "scripts": {
24
- "prepare:workspace-deps": "node ../../scripts/run-workspace-prep.mjs ../core ../agent-sdk",
24
+ "prepare:workspace-deps": "node ../../scripts/run-workspace-prep.mjs ../core ../agent-sdk ../coding-agent-host",
25
25
  "build": "npm run prepare:workspace-deps && node -e \"require('fs').rmSync('dist',{recursive:true,force:true})\" && tsc",
26
26
  "dev": "npm run prepare:workspace-deps && tsc --watch",
27
27
  "smoke": "node scripts/smoke-test.mjs",
@@ -30,6 +30,7 @@
30
30
  },
31
31
  "dependencies": {
32
32
  "@canonmsg/agent-sdk": "^3.2.3",
33
+ "@canonmsg/coding-agent-host": "^0.2.0",
33
34
  "@canonmsg/core": "^2.6.0"
34
35
  },
35
36
  "engines": {
package/dist/outbox.d.ts DELETED
@@ -1,83 +0,0 @@
1
- /**
2
- * Turn-end media outbox for Canon coding hosts.
3
- *
4
- * The host advertises a per-conversation outbox directory inside the session
5
- * working directory (`<cwd>/.canon/outbox/`). When the runtime wants a file
6
- * (screenshot, plot, artifact) delivered to the Canon conversation it writes
7
- * the file there — an explicit channel, never inferred from reply prose. At
8
- * turn end the host scans the outbox, uploads each regular file as a Canon
9
- * media attachment, and removes files that were delivered. Failed uploads
10
- * stay in place for a later turn; subdirectories, symlinks, and dotfiles are
11
- * ignored.
12
- *
13
- * This module is intentionally identical in packages/claude-code-plugin and
14
- * packages/codex-plugin — keep both copies in sync (future consolidation
15
- * candidate).
16
- */
17
- export declare const OUTBOX_MAX_FILES_PER_TURN = 8;
18
- export declare const OUTBOX_MAX_FILE_BYTES: number;
19
- export interface OutboxFile {
20
- path: string;
21
- fileName: string;
22
- sizeBytes: number;
23
- }
24
- export type OutboxSkipReason = 'not-regular-file' | 'hidden' | 'too-large' | 'file-cap';
25
- export interface OutboxScanResult {
26
- /** Regular files eligible for upload this turn, ordered by file name. */
27
- files: OutboxFile[];
28
- skipped: Array<{
29
- fileName: string;
30
- reason: OutboxSkipReason;
31
- }>;
32
- }
33
- export interface OutboxFlushResult {
34
- sent: Array<{
35
- file: OutboxFile;
36
- messageId: string;
37
- removeFailed?: true;
38
- }>;
39
- failed: Array<{
40
- file: OutboxFile;
41
- error: string;
42
- }>;
43
- skipped: OutboxScanResult['skipped'];
44
- }
45
- export declare function resolveOutboxDir(sessionCwd: string): string;
46
- /**
47
- * Create the outbox directory for a session and drop a `.gitignore` into the
48
- * host-managed `.canon/` dir (only when absent) so outbox state never shows
49
- * up as untracked dirt inside project checkouts or conversation worktrees.
50
- */
51
- export declare function ensureOutboxDir(sessionCwd: string): Promise<string>;
52
- /**
53
- * The one terse paragraph injected into the runtime's Canon context so the
54
- * agent knows the outbox exists. Hosts may append their own extra sentence
55
- * (e.g. an immediate-send tool) but must not paraphrase the convention.
56
- */
57
- export declare function buildOutboxContextLine(sessionCwd: string): string;
58
- /**
59
- * Discover the outbox files eligible for upload this turn. A missing outbox
60
- * directory is an empty result. Entries are ordered by file name so multi-file
61
- * turns deliver deterministically; everything past the per-turn cap (or over
62
- * the size cap) is left in place and reported as skipped.
63
- */
64
- export declare function scanOutbox(outboxDir: string, options?: {
65
- maxFiles?: number;
66
- maxFileBytes?: number;
67
- }): Promise<OutboxScanResult>;
68
- /**
69
- * Upload-and-consume pass over the outbox. Each eligible file is handed to
70
- * `send`; on success the file is removed (consumed), on failure it is left in
71
- * place for a later turn. A failed removal after a successful send is still
72
- * reported as sent (flagged `removeFailed`) so callers can warn about a
73
- * potential duplicate next turn instead of re-reporting a delivery failure.
74
- */
75
- export declare function flushOutbox(input: {
76
- outboxDir: string;
77
- send: (file: OutboxFile) => Promise<{
78
- messageId: string;
79
- }>;
80
- maxFiles?: number;
81
- maxFileBytes?: number;
82
- remove?: (path: string) => Promise<void>;
83
- }): Promise<OutboxFlushResult>;
package/dist/outbox.js DELETED
@@ -1,132 +0,0 @@
1
- /**
2
- * Turn-end media outbox for Canon coding hosts.
3
- *
4
- * The host advertises a per-conversation outbox directory inside the session
5
- * working directory (`<cwd>/.canon/outbox/`). When the runtime wants a file
6
- * (screenshot, plot, artifact) delivered to the Canon conversation it writes
7
- * the file there — an explicit channel, never inferred from reply prose. At
8
- * turn end the host scans the outbox, uploads each regular file as a Canon
9
- * media attachment, and removes files that were delivered. Failed uploads
10
- * stay in place for a later turn; subdirectories, symlinks, and dotfiles are
11
- * ignored.
12
- *
13
- * This module is intentionally identical in packages/claude-code-plugin and
14
- * packages/codex-plugin — keep both copies in sync (future consolidation
15
- * candidate).
16
- */
17
- import { mkdir, readdir, stat, unlink, writeFile } from 'node:fs/promises';
18
- import { join } from 'node:path';
19
- export const OUTBOX_MAX_FILES_PER_TURN = 8;
20
- export const OUTBOX_MAX_FILE_BYTES = 25 * 1024 * 1024;
21
- export function resolveOutboxDir(sessionCwd) {
22
- return join(sessionCwd, '.canon', 'outbox');
23
- }
24
- /**
25
- * Create the outbox directory for a session and drop a `.gitignore` into the
26
- * host-managed `.canon/` dir (only when absent) so outbox state never shows
27
- * up as untracked dirt inside project checkouts or conversation worktrees.
28
- */
29
- export async function ensureOutboxDir(sessionCwd) {
30
- const dir = resolveOutboxDir(sessionCwd);
31
- await mkdir(dir, { recursive: true });
32
- try {
33
- // The `*` pattern ignores everything under .canon, including this file.
34
- await writeFile(join(sessionCwd, '.canon', '.gitignore'), '*\n', { flag: 'wx' });
35
- }
36
- catch {
37
- // Already present (or unwritable) — never block session startup on it.
38
- }
39
- return dir;
40
- }
41
- /**
42
- * The one terse paragraph injected into the runtime's Canon context so the
43
- * agent knows the outbox exists. Hosts may append their own extra sentence
44
- * (e.g. an immediate-send tool) but must not paraphrase the convention.
45
- */
46
- export function buildOutboxContextLine(sessionCwd) {
47
- const maxMb = Math.floor(OUTBOX_MAX_FILE_BYTES / (1024 * 1024));
48
- return `Media outbox: to deliver a file (screenshot, plot, artifact) to this Canon conversation, write it into ${resolveOutboxDir(sessionCwd)} — when your turn ends the host uploads each regular file there as a chat attachment and then deletes it. Limits: ${OUTBOX_MAX_FILES_PER_TURN} files per turn and ${maxMb}MB per file; subdirectories, symlinks, and dotfiles are ignored.`;
49
- }
50
- /**
51
- * Discover the outbox files eligible for upload this turn. A missing outbox
52
- * directory is an empty result. Entries are ordered by file name so multi-file
53
- * turns deliver deterministically; everything past the per-turn cap (or over
54
- * the size cap) is left in place and reported as skipped.
55
- */
56
- export async function scanOutbox(outboxDir, options) {
57
- const maxFiles = options?.maxFiles ?? OUTBOX_MAX_FILES_PER_TURN;
58
- const maxFileBytes = options?.maxFileBytes ?? OUTBOX_MAX_FILE_BYTES;
59
- let entries;
60
- try {
61
- entries = await readdir(outboxDir, { withFileTypes: true });
62
- }
63
- catch (error) {
64
- if (error.code === 'ENOENT') {
65
- return { files: [], skipped: [] };
66
- }
67
- throw error;
68
- }
69
- const files = [];
70
- const skipped = [];
71
- for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) {
72
- // `readdir` does not follow symlinks, so a symlinked file reports
73
- // isSymbolicLink(), not isFile() — links and directories both land here.
74
- if (!entry.isFile()) {
75
- skipped.push({ fileName: entry.name, reason: 'not-regular-file' });
76
- continue;
77
- }
78
- if (entry.name.startsWith('.')) {
79
- skipped.push({ fileName: entry.name, reason: 'hidden' });
80
- continue;
81
- }
82
- const path = join(outboxDir, entry.name);
83
- const info = await stat(path);
84
- if (info.size > maxFileBytes) {
85
- skipped.push({ fileName: entry.name, reason: 'too-large' });
86
- continue;
87
- }
88
- if (files.length >= maxFiles) {
89
- skipped.push({ fileName: entry.name, reason: 'file-cap' });
90
- continue;
91
- }
92
- files.push({ path, fileName: entry.name, sizeBytes: info.size });
93
- }
94
- return { files, skipped };
95
- }
96
- /**
97
- * Upload-and-consume pass over the outbox. Each eligible file is handed to
98
- * `send`; on success the file is removed (consumed), on failure it is left in
99
- * place for a later turn. A failed removal after a successful send is still
100
- * reported as sent (flagged `removeFailed`) so callers can warn about a
101
- * potential duplicate next turn instead of re-reporting a delivery failure.
102
- */
103
- export async function flushOutbox(input) {
104
- const { files, skipped } = await scanOutbox(input.outboxDir, {
105
- ...(input.maxFiles != null ? { maxFiles: input.maxFiles } : {}),
106
- ...(input.maxFileBytes != null ? { maxFileBytes: input.maxFileBytes } : {}),
107
- });
108
- const remove = input.remove ?? ((path) => unlink(path));
109
- const sent = [];
110
- const failed = [];
111
- for (const file of files) {
112
- let messageId;
113
- try {
114
- ({ messageId } = await input.send(file));
115
- }
116
- catch (error) {
117
- failed.push({
118
- file,
119
- error: error instanceof Error ? error.message : String(error),
120
- });
121
- continue;
122
- }
123
- try {
124
- await remove(file.path);
125
- sent.push({ file, messageId });
126
- }
127
- catch {
128
- sent.push({ file, messageId, removeFailed: true });
129
- }
130
- }
131
- return { sent, failed, skipped };
132
- }