crewly 1.20.327 โ†’ 1.20.328

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.
Files changed (55) hide show
  1. package/config/skills/agent/core/reply-channel/SKILL.md +19 -0
  2. package/config/skills/agent/core/reply-channel/execute.sh +38 -14
  3. package/config/skills/agent/core/reply-channel/execute.test.sh +18 -0
  4. package/config/skills/agent/core/send-file/SKILL.md +5 -0
  5. package/config/skills/agent/core/slack-post/SKILL.md +1 -0
  6. package/config/skills/agent/core/slack-post/execute.sh +20 -4
  7. package/dist/backend/backend/src/constants.d.ts +33 -0
  8. package/dist/backend/backend/src/constants.d.ts.map +1 -1
  9. package/dist/backend/backend/src/constants.js +40 -0
  10. package/dist/backend/backend/src/constants.js.map +1 -1
  11. package/dist/backend/backend/src/controllers/chat-v2/chat-v2.controller.d.ts.map +1 -1
  12. package/dist/backend/backend/src/controllers/chat-v2/chat-v2.controller.js +31 -1
  13. package/dist/backend/backend/src/controllers/chat-v2/chat-v2.controller.js.map +1 -1
  14. package/dist/backend/backend/src/controllers/slack/slack.controller.d.ts.map +1 -1
  15. package/dist/backend/backend/src/controllers/slack/slack.controller.js +16 -2
  16. package/dist/backend/backend/src/controllers/slack/slack.controller.js.map +1 -1
  17. package/dist/backend/backend/src/index.d.ts.map +1 -1
  18. package/dist/backend/backend/src/index.js +17 -0
  19. package/dist/backend/backend/src/index.js.map +1 -1
  20. package/dist/backend/backend/src/services/ai/prompt-modules/skills-reference.module.d.ts.map +1 -1
  21. package/dist/backend/backend/src/services/ai/prompt-modules/skills-reference.module.js +4 -0
  22. package/dist/backend/backend/src/services/ai/prompt-modules/skills-reference.module.js.map +1 -1
  23. package/dist/backend/backend/src/services/chat-v2/chat-v2.dispatcher.service.d.ts +7 -0
  24. package/dist/backend/backend/src/services/chat-v2/chat-v2.dispatcher.service.d.ts.map +1 -1
  25. package/dist/backend/backend/src/services/chat-v2/chat-v2.dispatcher.service.js +13 -2
  26. package/dist/backend/backend/src/services/chat-v2/chat-v2.dispatcher.service.js.map +1 -1
  27. package/dist/backend/backend/src/services/file-handoff/file-handoff.inbound.d.ts +35 -0
  28. package/dist/backend/backend/src/services/file-handoff/file-handoff.inbound.d.ts.map +1 -0
  29. package/dist/backend/backend/src/services/file-handoff/file-handoff.inbound.js +45 -0
  30. package/dist/backend/backend/src/services/file-handoff/file-handoff.inbound.js.map +1 -0
  31. package/dist/backend/backend/src/services/file-handoff/file-handoff.outbound.d.ts +44 -0
  32. package/dist/backend/backend/src/services/file-handoff/file-handoff.outbound.d.ts.map +1 -0
  33. package/dist/backend/backend/src/services/file-handoff/file-handoff.outbound.js +50 -0
  34. package/dist/backend/backend/src/services/file-handoff/file-handoff.outbound.js.map +1 -0
  35. package/dist/backend/backend/src/services/file-handoff/file-handoff.service.d.ts +224 -0
  36. package/dist/backend/backend/src/services/file-handoff/file-handoff.service.d.ts.map +1 -0
  37. package/dist/backend/backend/src/services/file-handoff/file-handoff.service.js +454 -0
  38. package/dist/backend/backend/src/services/file-handoff/file-handoff.service.js.map +1 -0
  39. package/dist/backend/backend/src/services/file-handoff/file-handoff.wiring.d.ts +42 -0
  40. package/dist/backend/backend/src/services/file-handoff/file-handoff.wiring.d.ts.map +1 -0
  41. package/dist/backend/backend/src/services/file-handoff/file-handoff.wiring.js +92 -0
  42. package/dist/backend/backend/src/services/file-handoff/file-handoff.wiring.js.map +1 -0
  43. package/dist/backend/build-info.json +2 -2
  44. package/dist/cli/backend/src/constants.d.ts +33 -0
  45. package/dist/cli/backend/src/constants.d.ts.map +1 -1
  46. package/dist/cli/backend/src/constants.js +40 -0
  47. package/dist/cli/backend/src/constants.js.map +1 -1
  48. package/dist/cli/backend/src/services/ai/prompt-modules/skills-reference.module.d.ts.map +1 -1
  49. package/dist/cli/backend/src/services/ai/prompt-modules/skills-reference.module.js +4 -0
  50. package/dist/cli/backend/src/services/ai/prompt-modules/skills-reference.module.js.map +1 -1
  51. package/dist/cli/backend/src/services/chat-v2/chat-v2.dispatcher.service.d.ts +7 -0
  52. package/dist/cli/backend/src/services/chat-v2/chat-v2.dispatcher.service.d.ts.map +1 -1
  53. package/dist/cli/backend/src/services/chat-v2/chat-v2.dispatcher.service.js +13 -2
  54. package/dist/cli/backend/src/services/chat-v2/chat-v2.dispatcher.service.js.map +1 -1
  55. package/package.json +1 -1
@@ -0,0 +1,224 @@
1
+ /**
2
+ * Automatic cross-machine file hand-off.
3
+ *
4
+ * Agents on different machines cannot open each other's local paths. This
5
+ * service moves the file through Cloud's file-handoff relay (the same one the
6
+ * `send-file` / `receive-file` skills use) so nobody has to paste an
7
+ * instruction by hand:
8
+ *
9
+ * - Sender: {@link FileHandoffService.attachToMessage} uploads the files an
10
+ * agent attached (`--file`) or mentioned by path, and appends one compact
11
+ * marker line per file to the message text.
12
+ * - Receiver: {@link FileHandoffService.expandInbound} finds the markers in
13
+ * a delivered message, downloads each file into the project inbox, and
14
+ * rewrites the marker to the local path.
15
+ *
16
+ * Marker format (readable in Slack): `๐Ÿ“Ž report.pdf (2.1 MB) ยท crewly-file:hf_xxx`.
17
+ *
18
+ * @module services/file-handoff/file-handoff.service
19
+ */
20
+ /** The slice of the Apps Cloud client this service calls. */
21
+ export interface HandoffApi {
22
+ request<T>(method: string, path: string, opts?: {
23
+ body?: unknown;
24
+ agent?: string;
25
+ asOwner?: boolean;
26
+ timeoutMs?: number;
27
+ }): Promise<T>;
28
+ }
29
+ /** Streams a local file to a presigned URL. */
30
+ export type PutFileFn = (target: {
31
+ url: string;
32
+ headers: Record<string, string>;
33
+ }, file: string, sizeBytes: number, timeoutMs: number) => Promise<void>;
34
+ /** Streams a presigned URL to a local file. */
35
+ export type GetFileFn = (url: string, dest: string, timeoutMs: number) => Promise<void>;
36
+ /** Dependencies (all injectable for tests). */
37
+ export interface FileHandoffDeps {
38
+ api: () => HandoffApi;
39
+ /** Directories no file may be read from (Crewly home) */
40
+ crewlyHomes?: () => string[];
41
+ putFile?: PutFileFn;
42
+ getFile?: GetFileFn;
43
+ log?: (level: 'info' | 'warn', message: string, meta?: Record<string, unknown>) => void;
44
+ }
45
+ /** One file that went (or was meant to go) with a message. */
46
+ export interface HandoffAttachment {
47
+ fileName: string;
48
+ sizeBytes: number;
49
+ handoffId: string;
50
+ }
51
+ /** A file that did not go, with the reason an agent can act on. */
52
+ export interface HandoffFailure {
53
+ file: string;
54
+ reason: string;
55
+ }
56
+ /** Outcome of {@link FileHandoffService.attachToMessage}. */
57
+ export interface AttachResult {
58
+ /** The message text with marker lines appended (unchanged when nothing went) */
59
+ content: string;
60
+ attached: HandoffAttachment[];
61
+ failed: HandoffFailure[];
62
+ }
63
+ /** Input of {@link FileHandoffService.attachToMessage}. */
64
+ export interface AttachInput {
65
+ content: string;
66
+ /** Paths the agent named with `--file` (always attempted; failures are reported) */
67
+ explicit?: readonly string[];
68
+ /** Also pick up paths written in the text (only for a room that has a colleague on another machine) */
69
+ auto?: boolean;
70
+ /** The agent's project directory: relative paths resolve here and no file outside it goes */
71
+ projectDir: string;
72
+ /** Agent session the upload is attributed to */
73
+ agent?: string;
74
+ }
75
+ /** Outcome of {@link FileHandoffService.downloadToInbox}. */
76
+ export interface InboxFile {
77
+ path: string;
78
+ fileName: string;
79
+ sizeBytes: number;
80
+ }
81
+ /**
82
+ * Human size, e.g. `2.1 MB`.
83
+ *
84
+ * @param bytes - Size in bytes
85
+ * @returns Short size string
86
+ */
87
+ export declare function formatSize(bytes: number): string;
88
+ /**
89
+ * The marker line for one uploaded file.
90
+ *
91
+ * @param fileName - Name the receiver sees
92
+ * @param sizeBytes - File size
93
+ * @param handoffId - Cloud handoff id
94
+ * @returns `๐Ÿ“Ž report.pdf (2.1 MB) ยท crewly-file:hf_xxx`
95
+ */
96
+ export declare function formatMarker(fileName: string, sizeBytes: number, handoffId: string): string;
97
+ /**
98
+ * Handoff ids named by markers in a text, first occurrence order, no repeats.
99
+ *
100
+ * @param text - Message text
101
+ * @returns Handoff ids
102
+ */
103
+ export declare function findMarkerIds(text: string): string[];
104
+ /**
105
+ * Absolute (`/x/y`) and `./relative` paths written in a text.
106
+ *
107
+ * @param text - Message text
108
+ * @returns Candidate paths in order, without duplicates
109
+ */
110
+ export declare function extractPathCandidates(text: string): string[];
111
+ /**
112
+ * Whether a file name or path is one that must never leave the machine
113
+ * (`.env*`, keys, credentials, anything under `.ssh` / `.aws` / `.git`).
114
+ *
115
+ * @param file - File path
116
+ * @returns True for an obvious secret
117
+ */
118
+ export declare function isSecretPath(file: string): boolean;
119
+ /** A vetted file, or why it was refused. */
120
+ type Vetted = {
121
+ ok: true;
122
+ real: string;
123
+ size: number;
124
+ } | {
125
+ ok: false;
126
+ reason: string;
127
+ };
128
+ /** Default {@link PutFileFn}: a streamed `PUT` with an exact Content-Length. */
129
+ export declare const putFileStreamed: PutFileFn;
130
+ /** Default {@link GetFileFn}: a streamed `GET` written to `dest`. */
131
+ export declare const getFileStreamed: GetFileFn;
132
+ /**
133
+ * Uploads files for outgoing messages and downloads them for incoming ones.
134
+ */
135
+ export declare class FileHandoffService {
136
+ private readonly deps;
137
+ private readonly putFile;
138
+ private readonly getFile;
139
+ constructor(deps: FileHandoffDeps);
140
+ private log;
141
+ private homes;
142
+ /**
143
+ * Check a candidate file against every guard.
144
+ *
145
+ * Refuses: missing, symlink, not a regular file, empty, over the cap, outside
146
+ * the project, inside Crewly's home, an obvious secret name, or text that
147
+ * the secret redactor flags.
148
+ *
149
+ * @param candidate - Path as written
150
+ * @param projectDir - The agent's project directory
151
+ * @returns The real path and size, or the reason
152
+ */
153
+ vetFile(candidate: string, projectDir: string): Vetted;
154
+ /**
155
+ * Upload one vetted file as a handoff.
156
+ *
157
+ * @param file - Real path
158
+ * @param sizeBytes - Size
159
+ * @param agent - Agent session to attribute the upload to
160
+ * @returns The handoff
161
+ * @throws When Cloud or the storage upload fails (the half-made handoff is withdrawn)
162
+ */
163
+ uploadFile(file: string, sizeBytes: number, agent?: string): Promise<HandoffAttachment>;
164
+ /**
165
+ * Upload the files of one outgoing message and append their marker lines.
166
+ * Never throws: a file that cannot go is reported in `failed` and the text
167
+ * is still returned so the message can be posted anyway.
168
+ *
169
+ * @param input - Text, explicit files, auto-detect switch, project dir
170
+ * @returns New text plus what went and what did not
171
+ */
172
+ attachToMessage(input: AttachInput): Promise<AttachResult>;
173
+ /**
174
+ * Download one handoff into `<inboxRoot>/<handoffId>/<fileName>`.
175
+ *
176
+ * Not acknowledged: a room message reaches every machine (and every agent)
177
+ * in the room, and an ack deletes the Cloud copy, so the first receiver
178
+ * would take it from all the others. Cloud removes it after 24 h. A file
179
+ * already in this inbox is reused, and two local agents fetching the same
180
+ * one share a single download.
181
+ *
182
+ * @param handoffId - Id from a marker
183
+ * @param inboxRoot - The inbox folder (`<project data dir>/inbox`)
184
+ * @param agent - Receiving agent session
185
+ * @param timeoutMs - Ceiling for the transfer
186
+ * @returns Where the file is now
187
+ * @throws When the handoff is not ready, the transfer fails, or the size differs
188
+ */
189
+ downloadToInbox(handoffId: string, inboxRoot: string, agent: string | undefined, timeoutMs?: number): Promise<InboxFile>;
190
+ /** Downloads in progress, by `<inboxRoot>/<handoffId>`. */
191
+ private readonly inFlight;
192
+ /**
193
+ * The finished file in a handoff's inbox folder, if there is one.
194
+ *
195
+ * @param dir - `<inboxRoot>/<handoffId>`
196
+ * @returns The file, or null
197
+ */
198
+ private inboxFileIn;
199
+ /**
200
+ * The transfer itself, for {@link downloadToInbox}.
201
+ *
202
+ * @param handoffId - Id from a marker
203
+ * @param inboxRoot - The inbox folder
204
+ * @param agent - Receiving agent session
205
+ * @param timeoutMs - Ceiling for the transfer
206
+ * @returns Where the file is now
207
+ */
208
+ private fetchToInbox;
209
+ /**
210
+ * Replace every marker in a delivered message with the local path of the
211
+ * downloaded file (`File from <sender>: <path>`). A file that cannot be
212
+ * fetched in time stays in the text with a note and the manual command.
213
+ * Downloads run in parallel under one overall deadline.
214
+ *
215
+ * @param text - Message text as delivered
216
+ * @param sender - Who sent it (display name)
217
+ * @param inboxRoot - The receiving agent's inbox folder
218
+ * @param agent - Receiving agent session
219
+ * @returns Text with the markers rewritten (unchanged when it has none)
220
+ */
221
+ expandInbound(text: string, sender: string, inboxRoot: string, agent?: string): Promise<string>;
222
+ }
223
+ export {};
224
+ //# sourceMappingURL=file-handoff.service.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"file-handoff.service.d.ts","sourceRoot":"","sources":["../../../../../../backend/src/services/file-handoff/file-handoff.service.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,454 @@
1
+ /**
2
+ * Automatic cross-machine file hand-off.
3
+ *
4
+ * Agents on different machines cannot open each other's local paths. This
5
+ * service moves the file through Cloud's file-handoff relay (the same one the
6
+ * `send-file` / `receive-file` skills use) so nobody has to paste an
7
+ * instruction by hand:
8
+ *
9
+ * - Sender: {@link FileHandoffService.attachToMessage} uploads the files an
10
+ * agent attached (`--file`) or mentioned by path, and appends one compact
11
+ * marker line per file to the message text.
12
+ * - Receiver: {@link FileHandoffService.expandInbound} finds the markers in
13
+ * a delivered message, downloads each file into the project inbox, and
14
+ * rewrites the marker to the local path.
15
+ *
16
+ * Marker format (readable in Slack): `๐Ÿ“Ž report.pdf (2.1 MB) ยท crewly-file:hf_xxx`.
17
+ *
18
+ * @module services/file-handoff/file-handoff.service
19
+ */
20
+ import * as fs from 'fs';
21
+ import * as http from 'http';
22
+ import * as https from 'https';
23
+ import * as os from 'os';
24
+ import * as path from 'path';
25
+ import { FILE_HANDOFF_CONSTANTS as C } from '../../constants.js';
26
+ import { redactSecretsWithCount } from '../../utils/secret-redactor.js';
27
+ // ---------------------------------------------------------------------------
28
+ // Pure helpers
29
+ // ---------------------------------------------------------------------------
30
+ const MARKER_ID_RE = new RegExp(`${escapeRe(C.MARKER_PREFIX)}(${C.ID_PATTERN})`, 'g');
31
+ const MARKER_TOKEN_RE = new RegExp(`(?:${escapeRe(C.MARKER_GLYPH)}[^\\n]*?ยท\\s*)?${escapeRe(C.MARKER_PREFIX)}(${C.ID_PATTERN})`, 'g');
32
+ const KIB = 1024;
33
+ const SIZE_UNITS = ['B', 'KB', 'MB', 'GB'];
34
+ /** Characters that end a path written in prose */
35
+ const PATH_TRAILING_PUNCT = /[.,;:!?)\]}>'"`]+$/;
36
+ /** Absolute or `./relative` paths in free text */
37
+ const PATH_IN_TEXT_RE = /(?:^|[\s"'`(<[])((?:\/|\.\/)[^\s"'`)>\]]+)/g;
38
+ function escapeRe(s) {
39
+ return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
40
+ }
41
+ /**
42
+ * Human size, e.g. `2.1 MB`.
43
+ *
44
+ * @param bytes - Size in bytes
45
+ * @returns Short size string
46
+ */
47
+ export function formatSize(bytes) {
48
+ let value = bytes;
49
+ let unit = 0;
50
+ while (value >= KIB && unit < SIZE_UNITS.length - 1) {
51
+ value /= KIB;
52
+ unit += 1;
53
+ }
54
+ const text = unit === 0 || value >= 10 ? String(Math.round(value)) : value.toFixed(1);
55
+ return `${text} ${SIZE_UNITS[unit]}`;
56
+ }
57
+ /**
58
+ * The marker line for one uploaded file.
59
+ *
60
+ * @param fileName - Name the receiver sees
61
+ * @param sizeBytes - File size
62
+ * @param handoffId - Cloud handoff id
63
+ * @returns `๐Ÿ“Ž report.pdf (2.1 MB) ยท crewly-file:hf_xxx`
64
+ */
65
+ export function formatMarker(fileName, sizeBytes, handoffId) {
66
+ const safeName = fileName.replace(/[\r\n]+/g, ' ').trim() || 'file';
67
+ return `${C.MARKER_GLYPH} ${safeName} (${formatSize(sizeBytes)}) ยท ${C.MARKER_PREFIX}${handoffId}`;
68
+ }
69
+ /**
70
+ * Handoff ids named by markers in a text, first occurrence order, no repeats.
71
+ *
72
+ * @param text - Message text
73
+ * @returns Handoff ids
74
+ */
75
+ export function findMarkerIds(text) {
76
+ const ids = [];
77
+ for (const m of text.matchAll(MARKER_ID_RE)) {
78
+ if (!ids.includes(m[1]))
79
+ ids.push(m[1]);
80
+ }
81
+ return ids;
82
+ }
83
+ /**
84
+ * Absolute (`/x/y`) and `./relative` paths written in a text.
85
+ *
86
+ * @param text - Message text
87
+ * @returns Candidate paths in order, without duplicates
88
+ */
89
+ export function extractPathCandidates(text) {
90
+ const found = [];
91
+ for (const m of text.matchAll(PATH_IN_TEXT_RE)) {
92
+ const p = m[1].replace(PATH_TRAILING_PUNCT, '');
93
+ if (p.length > 2 && !found.includes(p))
94
+ found.push(p);
95
+ }
96
+ return found;
97
+ }
98
+ /**
99
+ * Whether a file name or path is one that must never leave the machine
100
+ * (`.env*`, keys, credentials, anything under `.ssh` / `.aws` / `.git`).
101
+ *
102
+ * @param file - File path
103
+ * @returns True for an obvious secret
104
+ */
105
+ export function isSecretPath(file) {
106
+ const parts = file.split(path.sep).filter(Boolean).map((s) => s.toLowerCase());
107
+ const name = parts[parts.length - 1] ?? '';
108
+ if (C.SECRET_NAME_PATTERNS.some((re) => re.test(name)))
109
+ return true;
110
+ return parts.slice(0, -1).some((d) => C.SECRET_DIR_NAMES.includes(d));
111
+ }
112
+ function within(child, parent) {
113
+ return child === parent || child.startsWith(parent.endsWith(path.sep) ? parent : parent + path.sep);
114
+ }
115
+ function real(p) {
116
+ try {
117
+ return fs.realpathSync(p);
118
+ }
119
+ catch {
120
+ return null;
121
+ }
122
+ }
123
+ /** A safe local file name (last segment, no leading dots or control characters). */
124
+ function safeName(name) {
125
+ // eslint-disable-next-line no-control-regex
126
+ const base = name.replace(/\\/g, '/').split('/').pop().replace(/[\u0000-\u001f]/g, '').replace(/^[.\s]+/, '').trim();
127
+ return base || 'file';
128
+ }
129
+ // ---------------------------------------------------------------------------
130
+ // Network (streamed from / to disk, never buffered)
131
+ // ---------------------------------------------------------------------------
132
+ /** Default {@link PutFileFn}: a streamed `PUT` with an exact Content-Length. */
133
+ export const putFileStreamed = (target, file, sizeBytes, timeoutMs) => new Promise((resolve, reject) => {
134
+ const url = new URL(target.url);
135
+ const lib = url.protocol === 'http:' ? http : https;
136
+ const req = lib.request(url, { method: 'PUT', headers: { ...target.headers, 'Content-Length': String(sizeBytes) }, timeout: timeoutMs }, (res) => {
137
+ res.resume();
138
+ res.on('end', () => (res.statusCode === 200 ? resolve() : reject(new Error(`upload failed (HTTP ${res.statusCode ?? 0})`))));
139
+ });
140
+ req.on('timeout', () => req.destroy(new Error('upload timed out')));
141
+ req.on('error', reject);
142
+ const src = fs.createReadStream(file);
143
+ src.on('error', (e) => req.destroy(e));
144
+ src.pipe(req);
145
+ });
146
+ /** Default {@link GetFileFn}: a streamed `GET` written to `dest`. */
147
+ export const getFileStreamed = (url, dest, timeoutMs) => new Promise((resolve, reject) => {
148
+ const target = new URL(url);
149
+ const lib = target.protocol === 'http:' ? http : https;
150
+ const req = lib.get(target, { timeout: timeoutMs }, (res) => {
151
+ if (res.statusCode !== 200) {
152
+ res.resume();
153
+ reject(new Error(`download failed (HTTP ${res.statusCode ?? 0})`));
154
+ return;
155
+ }
156
+ const out = fs.createWriteStream(dest);
157
+ res.pipe(out);
158
+ out.on('finish', () => out.close(() => resolve()));
159
+ out.on('error', reject);
160
+ res.on('error', reject);
161
+ });
162
+ req.on('timeout', () => req.destroy(new Error('download timed out')));
163
+ req.on('error', reject);
164
+ });
165
+ // ---------------------------------------------------------------------------
166
+ // Service
167
+ // ---------------------------------------------------------------------------
168
+ /**
169
+ * Uploads files for outgoing messages and downloads them for incoming ones.
170
+ */
171
+ export class FileHandoffService {
172
+ deps;
173
+ putFile;
174
+ getFile;
175
+ constructor(deps) {
176
+ this.deps = deps;
177
+ this.putFile = deps.putFile ?? putFileStreamed;
178
+ this.getFile = deps.getFile ?? getFileStreamed;
179
+ }
180
+ log(level, message, meta) {
181
+ this.deps.log?.(level, message, meta);
182
+ }
183
+ homes() {
184
+ const configured = this.deps.crewlyHomes?.() ?? [path.join(os.homedir(), '.crewly')];
185
+ return configured.map((h) => real(h) ?? path.resolve(h));
186
+ }
187
+ /**
188
+ * Check a candidate file against every guard.
189
+ *
190
+ * Refuses: missing, symlink, not a regular file, empty, over the cap, outside
191
+ * the project, inside Crewly's home, an obvious secret name, or text that
192
+ * the secret redactor flags.
193
+ *
194
+ * @param candidate - Path as written
195
+ * @param projectDir - The agent's project directory
196
+ * @returns The real path and size, or the reason
197
+ */
198
+ vetFile(candidate, projectDir) {
199
+ const abs = path.resolve(projectDir, candidate);
200
+ let st;
201
+ try {
202
+ st = fs.lstatSync(abs);
203
+ }
204
+ catch {
205
+ return { ok: false, reason: 'file not found' };
206
+ }
207
+ if (st.isSymbolicLink())
208
+ return { ok: false, reason: 'is a symbolic link; pass the real file' };
209
+ if (!st.isFile())
210
+ return { ok: false, reason: 'is not a regular file' };
211
+ if (st.size === 0)
212
+ return { ok: false, reason: 'is empty' };
213
+ if (st.size > C.MAX_FILE_BYTES)
214
+ return { ok: false, reason: `is larger than the ${formatSize(C.MAX_FILE_BYTES)} limit` };
215
+ const r = real(abs);
216
+ const project = real(projectDir);
217
+ if (!r || !project)
218
+ return { ok: false, reason: 'cannot resolve the project directory' };
219
+ if (!within(r, project))
220
+ return { ok: false, reason: 'is outside your project directory; copy it there first' };
221
+ if (this.homes().some((h) => within(r, h)))
222
+ return { ok: false, reason: "is inside Crewly's home directory" };
223
+ // Check the path below the project root, so a project that lives under e.g. node_modules still works.
224
+ if (isSecretPath(path.relative(project, r)))
225
+ return { ok: false, reason: 'looks like a secret (credentials or key file) and is never sent' };
226
+ if (st.size <= C.SECRET_SCAN_MAX_BYTES && looksLikeText(r)) {
227
+ try {
228
+ if (redactSecretsWithCount(fs.readFileSync(r, 'utf8')).count > 0) {
229
+ return { ok: false, reason: 'contains what looks like a secret and is never sent' };
230
+ }
231
+ }
232
+ catch {
233
+ return { ok: false, reason: 'could not be read' };
234
+ }
235
+ }
236
+ return { ok: true, real: r, size: st.size };
237
+ }
238
+ /**
239
+ * Upload one vetted file as a handoff.
240
+ *
241
+ * @param file - Real path
242
+ * @param sizeBytes - Size
243
+ * @param agent - Agent session to attribute the upload to
244
+ * @returns The handoff
245
+ * @throws When Cloud or the storage upload fails (the half-made handoff is withdrawn)
246
+ */
247
+ async uploadFile(file, sizeBytes, agent) {
248
+ const api = this.deps.api();
249
+ const attr = agent ? { agent } : { asOwner: true };
250
+ const fileName = safeName(path.basename(file));
251
+ const created = await api.request('POST', '/handoffs', {
252
+ body: { fileName, sizeBytes, contentType: C.DEFAULT_CONTENT_TYPE },
253
+ ...attr,
254
+ });
255
+ const id = created.handoffId;
256
+ const url = created.upload?.url;
257
+ if (!id || !url)
258
+ throw new Error('Cloud answered without an upload URL');
259
+ try {
260
+ await this.putFile({ url, headers: created.upload?.headers ?? {} }, file, sizeBytes, C.UPLOAD_TIMEOUT_MS);
261
+ await api.request('POST', `/handoffs/${id}/complete`, attr);
262
+ }
263
+ catch (err) {
264
+ await api.request('DELETE', `/handoffs/${id}`, attr).catch(() => undefined);
265
+ throw err;
266
+ }
267
+ return { fileName, sizeBytes, handoffId: id };
268
+ }
269
+ /**
270
+ * Upload the files of one outgoing message and append their marker lines.
271
+ * Never throws: a file that cannot go is reported in `failed` and the text
272
+ * is still returned so the message can be posted anyway.
273
+ *
274
+ * @param input - Text, explicit files, auto-detect switch, project dir
275
+ * @returns New text plus what went and what did not
276
+ */
277
+ async attachToMessage(input) {
278
+ const attached = [];
279
+ const failed = [];
280
+ const tried = new Set();
281
+ const attempt = async (candidate, explicit) => {
282
+ const key = path.resolve(input.projectDir, candidate);
283
+ if (tried.has(key))
284
+ return;
285
+ tried.add(key);
286
+ if (attached.length >= C.MAX_FILES_PER_MESSAGE) {
287
+ if (explicit)
288
+ failed.push({ file: candidate, reason: `more than ${C.MAX_FILES_PER_MESSAGE} files in one message` });
289
+ return;
290
+ }
291
+ const vetted = this.vetFile(candidate, input.projectDir);
292
+ if (!vetted.ok) {
293
+ // A path merely mentioned in prose that is not a sendable file is not an error.
294
+ if (explicit)
295
+ failed.push({ file: candidate, reason: vetted.reason });
296
+ return;
297
+ }
298
+ try {
299
+ attached.push(await this.uploadFile(vetted.real, vetted.size, input.agent));
300
+ }
301
+ catch (err) {
302
+ const reason = err instanceof Error ? err.message : String(err);
303
+ this.log('warn', 'File handoff upload failed', { file: path.basename(candidate), reason });
304
+ failed.push({ file: candidate, reason });
305
+ }
306
+ };
307
+ for (const f of input.explicit ?? [])
308
+ await attempt(f, true);
309
+ if (input.auto) {
310
+ for (const f of extractPathCandidates(input.content)) {
311
+ if (attached.length >= C.MAX_FILES_PER_MESSAGE)
312
+ break;
313
+ await attempt(f, false);
314
+ }
315
+ }
316
+ const lines = attached.map((a) => formatMarker(a.fileName, a.sizeBytes, a.handoffId));
317
+ const content = lines.length > 0 ? `${input.content.replace(/\s+$/, '')}\n${lines.join('\n')}` : input.content;
318
+ return { content, attached, failed };
319
+ }
320
+ /**
321
+ * Download one handoff into `<inboxRoot>/<handoffId>/<fileName>`.
322
+ *
323
+ * Not acknowledged: a room message reaches every machine (and every agent)
324
+ * in the room, and an ack deletes the Cloud copy, so the first receiver
325
+ * would take it from all the others. Cloud removes it after 24 h. A file
326
+ * already in this inbox is reused, and two local agents fetching the same
327
+ * one share a single download.
328
+ *
329
+ * @param handoffId - Id from a marker
330
+ * @param inboxRoot - The inbox folder (`<project data dir>/inbox`)
331
+ * @param agent - Receiving agent session
332
+ * @param timeoutMs - Ceiling for the transfer
333
+ * @returns Where the file is now
334
+ * @throws When the handoff is not ready, the transfer fails, or the size differs
335
+ */
336
+ async downloadToInbox(handoffId, inboxRoot, agent, timeoutMs = C.DOWNLOAD_TIMEOUT_MS) {
337
+ const key = path.join(inboxRoot, handoffId);
338
+ const existing = this.inboxFileIn(key);
339
+ if (existing)
340
+ return existing;
341
+ const running = this.inFlight.get(key);
342
+ if (running)
343
+ return running;
344
+ const job = this.fetchToInbox(handoffId, inboxRoot, agent, timeoutMs).finally(() => this.inFlight.delete(key));
345
+ this.inFlight.set(key, job);
346
+ return job;
347
+ }
348
+ /** Downloads in progress, by `<inboxRoot>/<handoffId>`. */
349
+ inFlight = new Map();
350
+ /**
351
+ * The finished file in a handoff's inbox folder, if there is one.
352
+ *
353
+ * @param dir - `<inboxRoot>/<handoffId>`
354
+ * @returns The file, or null
355
+ */
356
+ inboxFileIn(dir) {
357
+ try {
358
+ const name = fs.readdirSync(dir).find((n) => !n.startsWith('.'));
359
+ if (!name)
360
+ return null;
361
+ const dest = path.join(dir, name);
362
+ return { path: dest, fileName: name, sizeBytes: fs.statSync(dest).size };
363
+ }
364
+ catch {
365
+ return null;
366
+ }
367
+ }
368
+ /**
369
+ * The transfer itself, for {@link downloadToInbox}.
370
+ *
371
+ * @param handoffId - Id from a marker
372
+ * @param inboxRoot - The inbox folder
373
+ * @param agent - Receiving agent session
374
+ * @param timeoutMs - Ceiling for the transfer
375
+ * @returns Where the file is now
376
+ */
377
+ async fetchToInbox(handoffId, inboxRoot, agent, timeoutMs) {
378
+ const api = this.deps.api();
379
+ const attr = agent ? { agent } : { asOwner: true };
380
+ const meta = await api.request('GET', `/handoffs/${handoffId}`, { ...attr, timeoutMs });
381
+ if (meta.status !== 'ready' || !meta.download?.url)
382
+ throw new Error('the sender has not finished uploading');
383
+ const size = Number(meta.sizeBytes);
384
+ const name = safeName(meta.fileName ?? 'file');
385
+ const dir = path.join(inboxRoot, handoffId);
386
+ fs.mkdirSync(dir, { recursive: true });
387
+ const dest = path.join(dir, name);
388
+ const part = path.join(dir, `.${handoffId}.part`);
389
+ try {
390
+ await this.getFile(meta.download.url, part, timeoutMs);
391
+ const got = fs.statSync(part).size;
392
+ if (Number.isFinite(size) && got !== size)
393
+ throw new Error(`size mismatch (got ${got} bytes, expected ${size})`);
394
+ fs.renameSync(part, dest);
395
+ }
396
+ catch (err) {
397
+ fs.rmSync(part, { force: true });
398
+ throw err;
399
+ }
400
+ return { path: dest, fileName: name, sizeBytes: fs.statSync(dest).size };
401
+ }
402
+ /**
403
+ * Replace every marker in a delivered message with the local path of the
404
+ * downloaded file (`File from <sender>: <path>`). A file that cannot be
405
+ * fetched in time stays in the text with a note and the manual command.
406
+ * Downloads run in parallel under one overall deadline.
407
+ *
408
+ * @param text - Message text as delivered
409
+ * @param sender - Who sent it (display name)
410
+ * @param inboxRoot - The receiving agent's inbox folder
411
+ * @param agent - Receiving agent session
412
+ * @returns Text with the markers rewritten (unchanged when it has none)
413
+ */
414
+ async expandInbound(text, sender, inboxRoot, agent) {
415
+ const ids = findMarkerIds(text);
416
+ if (ids.length === 0)
417
+ return text;
418
+ const deadline = Date.now() + C.DOWNLOAD_TOTAL_TIMEOUT_MS;
419
+ const results = new Map();
420
+ await Promise.all(ids.slice(0, C.MAX_FILES_PER_MESSAGE).map(async (id) => {
421
+ try {
422
+ const budget = Math.max(1, Math.min(C.DOWNLOAD_TIMEOUT_MS, deadline - Date.now()));
423
+ const file = await this.downloadToInbox(id, inboxRoot, agent, budget);
424
+ results.set(id, `File from ${sender}: ${file.path}`);
425
+ this.log('info', 'File handoff received', { handoffId: id, bytes: file.sizeBytes });
426
+ }
427
+ catch (err) {
428
+ const reason = err instanceof Error ? err.message : String(err);
429
+ this.log('warn', 'File handoff download failed', { handoffId: id, reason });
430
+ results.set(id, `File from ${sender} could not be fetched (${reason}). Fetch it yourself: receive-file --id ${id}`);
431
+ }
432
+ }));
433
+ return text.replace(MARKER_TOKEN_RE, (whole, id) => results.get(id) ?? whole);
434
+ }
435
+ }
436
+ /** Whether the first bytes look like text (no NUL byte). */
437
+ function looksLikeText(file) {
438
+ const probeBytes = 4096;
439
+ let fd = null;
440
+ try {
441
+ fd = fs.openSync(file, 'r');
442
+ const buf = Buffer.alloc(probeBytes);
443
+ const n = fs.readSync(fd, buf, 0, probeBytes, 0);
444
+ return !buf.subarray(0, n).includes(0);
445
+ }
446
+ catch {
447
+ return false;
448
+ }
449
+ finally {
450
+ if (fd !== null)
451
+ fs.closeSync(fd);
452
+ }
453
+ }
454
+ //# sourceMappingURL=file-handoff.service.js.map