@link-assistant/hive-mind 2.10.0 → 2.10.2

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 (45) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/README.hi.md +2 -0
  3. package/README.md +2 -0
  4. package/README.ru.md +2 -0
  5. package/README.zh.md +2 -0
  6. package/package.json +1 -1
  7. package/src/claude.lib.mjs +0 -4
  8. package/src/cleanup.mjs +18 -6
  9. package/src/codex.lib.mjs +0 -4
  10. package/src/configure-claude.mjs +3 -0
  11. package/src/credential-sanitization-core.lib.mjs +231 -0
  12. package/src/development-log.lib.mjs +39 -6
  13. package/src/fix.mjs +3 -0
  14. package/src/github-error-reporter.lib.mjs +13 -8
  15. package/src/github-issue-auto-close.lib.mjs +2 -1
  16. package/src/github-merge-issue-close.lib.mjs +2 -1
  17. package/src/github.lib.mjs +29 -18
  18. package/src/hive-screens.mjs +3 -0
  19. package/src/instrument.mjs +14 -0
  20. package/src/interactive-mode.lib.mjs +25 -40
  21. package/src/lib.mjs +89 -50
  22. package/src/log-upload.lib.mjs +22 -4
  23. package/src/post-finish-sanitization-sweep.lib.mjs +5 -5
  24. package/src/review.mjs +3 -1
  25. package/src/sentry.lib.mjs +27 -8
  26. package/src/session-monitor.lib.mjs +6 -10
  27. package/src/session-resume.lib.mjs +43 -23
  28. package/src/session-store.lib.mjs +3 -1
  29. package/src/solve.auto-pr.lib.mjs +11 -21
  30. package/src/solve.error-handlers.lib.mjs +2 -1
  31. package/src/solve.progress-monitoring.lib.mjs +20 -9
  32. package/src/solve.results.lib.mjs +9 -15
  33. package/src/start-screen.mjs +3 -0
  34. package/src/task.issue-creation.lib.mjs +5 -3
  35. package/src/task.mjs +21 -8
  36. package/src/telegram-bot.mjs +6 -3
  37. package/src/telegram-command-execution.lib.mjs +2 -2
  38. package/src/telegram-isolation.lib.mjs +2 -0
  39. package/src/telegram-log-command.lib.mjs +38 -4
  40. package/src/telegram-safe-reply.lib.mjs +7 -4
  41. package/src/telegram-solve-queue.lib.mjs +5 -6
  42. package/src/telegram-tokens-command.lib.mjs +1 -1
  43. package/src/token-sanitization.lib.mjs +177 -14
  44. package/src/tool-comments.lib.mjs +5 -5
  45. package/src/youtrack/youtrack-sync.mjs +4 -3
@@ -1,5 +1,24 @@
1
1
  // Sentry integration library for hive-mind
2
2
  import { isSentryEnabled, captureException, captureMessage, startTransaction } from './instrument.mjs';
3
+ import { sanitizeCredentialText } from './credential-sanitization-core.lib.mjs';
4
+
5
+ const sanitizeError = error => {
6
+ const source = error instanceof Error ? error : new Error(String(error));
7
+ const sanitized = new Error(sanitizeCredentialText(source.message));
8
+ sanitized.name = source.name;
9
+ if (source.stack) sanitized.stack = sanitizeCredentialText(source.stack);
10
+ return sanitized;
11
+ };
12
+
13
+ const sanitizeContext = (value, seen = new WeakSet()) => {
14
+ if (typeof value === 'string') return sanitizeCredentialText(value);
15
+ if (value instanceof Error) return sanitizeError(value);
16
+ if (!value || typeof value !== 'object') return value;
17
+ if (seen.has(value)) return '[Circular]';
18
+ seen.add(value);
19
+ if (Array.isArray(value)) return value.map(item => sanitizeContext(item, seen));
20
+ return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, sanitizeContext(item, seen)]));
21
+ };
3
22
 
4
23
  // Lazy import of Sentry to handle cases where it's not installed
5
24
  let Sentry = null;
@@ -84,7 +103,7 @@ export const withSentry = (fn, name, op = 'task') => {
84
103
  return result;
85
104
  } catch (error) {
86
105
  transaction.setStatus('internal_error');
87
- captureException(error, {
106
+ captureException(sanitizeError(error), {
88
107
  operation: name,
89
108
  args: args.length > 0 ? `${args.length} arguments` : 'no arguments',
90
109
  });
@@ -139,7 +158,7 @@ export const logToSentry = (message, level = 'info', context = {}) => {
139
158
  return;
140
159
  }
141
160
 
142
- captureMessage(message, level, context);
161
+ captureMessage(sanitizeCredentialText(message), level, sanitizeContext(context));
143
162
  };
144
163
 
145
164
  /**
@@ -153,7 +172,7 @@ export const reportError = (error, context = {}) => {
153
172
  return;
154
173
  }
155
174
 
156
- captureException(error, { ...context, level: 'error' });
175
+ captureException(sanitizeError(error), { ...sanitizeContext(context), level: 'error' });
157
176
  };
158
177
 
159
178
  /**
@@ -169,7 +188,7 @@ export const reportWarning = (warning, context = {}) => {
169
188
 
170
189
  // Convert string warnings to Error objects for better stack traces
171
190
  const warningError = typeof warning === 'string' ? new Error(warning) : warning;
172
- captureException(warningError, { ...context, level: 'warning' });
191
+ captureException(sanitizeError(warningError), { ...sanitizeContext(context), level: 'warning' });
173
192
  };
174
193
 
175
194
  /**
@@ -183,7 +202,7 @@ export const addBreadcrumb = async breadcrumb => {
183
202
 
184
203
  const sentry = await getSentry();
185
204
  if (sentry) {
186
- sentry.addBreadcrumb(breadcrumb);
205
+ sentry.addBreadcrumb(sanitizeContext(breadcrumb));
187
206
  }
188
207
  };
189
208
 
@@ -198,7 +217,7 @@ export const setUserContext = async user => {
198
217
 
199
218
  const sentry = await getSentry();
200
219
  if (sentry) {
201
- sentry.setUser(user);
220
+ sentry.setUser(sanitizeContext(user));
202
221
  }
203
222
  };
204
223
 
@@ -214,7 +233,7 @@ export const setExtraContext = async (key, value) => {
214
233
 
215
234
  const sentry = await getSentry();
216
235
  if (sentry) {
217
- sentry.setExtra(key, value);
236
+ sentry.setExtra(key, sanitizeContext(value));
218
237
  }
219
238
  };
220
239
 
@@ -229,7 +248,7 @@ export const setTags = async tags => {
229
248
 
230
249
  const sentry = await getSentry();
231
250
  if (sentry) {
232
- sentry.setTags(tags);
251
+ sentry.setTags(sanitizeContext(tags));
233
252
  }
234
253
  };
235
254
 
@@ -25,8 +25,7 @@ import { promisify } from 'util';
25
25
  import { formatSessionCompletionMessage, getSessionCompletionExitCode, classifySessionOutcome } from './work-session-formatting.lib.mjs';
26
26
  import { notifySubscribers, getSubscriberCount } from './telegram-subscribers.lib.mjs';
27
27
  import { classifyExitStatus, normalizeExitCode } from './session-status.lib.mjs';
28
- import path from 'node:path';
29
- import { readLastSessionIdFromLog, findLatestSessionLogId, buildResumeCommand, formatResumeSection } from './session-resume.lib.mjs';
28
+ import { readLastSessionIdFromLog, buildResumeCommand, formatResumeSection } from './session-resume.lib.mjs';
30
29
 
31
30
  export { formatSessionCompletionMessage, getSessionCompletionExitCode } from './work-session-formatting.lib.mjs';
32
31
 
@@ -913,14 +912,11 @@ export async function monitorSessions(bot, verbose = false, options = {}) {
913
912
  const logPath = statusResult?.logPath || sessionInfo?.logPath || null;
914
913
  // The id must be the AI TOOL's session id, not the isolation session
915
914
  // id (sessionInfo.sessionId — wrong namespace for `solve --resume`).
916
- // Prefer the last `Session ID:` marker in the captured log; fall
917
- // back to the newest `<sessionId>.log` start-command wrote in the
918
- // same directory. If neither exists, offer no command (a bogus
919
- // resume id would be worse than none).
920
- let lastSessionId = readLastSessionIdFromLog(logPath, { verbose });
921
- if (!lastSessionId && logPath) {
922
- lastSessionId = findLatestSessionLogId({ dir: path.dirname(logPath), verbose });
923
- }
915
+ // Scan backwards through this task's captured log for its last
916
+ // `Session ID:` marker. Do not guess from neighboring UUID-named
917
+ // logs: start-command stores unrelated tasks in the same backend
918
+ // directory, which caused issue #2109's invalid resume id.
919
+ const lastSessionId = readLastSessionIdFromLog(logPath, { verbose });
924
920
  const resumeCommand = buildResumeCommand({ sessionInfo, lastSessionId });
925
921
  const resumeSection = formatResumeSection({ lastSessionId, command: resumeCommand });
926
922
  if (resumeSection) {
@@ -11,11 +11,10 @@
11
11
  * 1. **Use the LAST session id.** A single `/solve` run can spin up *many*
12
12
  * tool sessions — auto-continue across usage-limit resets, uncommitted-
13
13
  * changes restarts (`solve.watch`), and manual `--resume` chains. Every one
14
- * prints a `Session ID:` marker to the captured log in chronological order,
15
- * and start-command also renames the per-session log to `<sessionId>.log`.
14
+ * prints a `Session ID:` marker to the captured log in chronological order.
16
15
  * The most advanced context lives in the *last* of these, so resuming must
17
- * pick the last id — never the first. {@link selectLastSessionId} /
18
- * {@link findLatestSessionLogId} enforce that rule.
16
+ * pick the last id — never the first. {@link selectLastSessionId} and
17
+ * {@link readLastSessionIdFromLog} enforce that rule.
19
18
  *
20
19
  * 2. **Never storm.** Auto-resuming a killed session must be bounded so a job
21
20
  * that reliably OOMs cannot spawn an infinite relaunch loop (which would be
@@ -41,6 +40,8 @@ const SESSION_ID_MARKER_RE = /Session ID:\s*`?([^\s`]+)`?/gi;
41
40
  // `<sessionId>.log` files start-command writes. Used to validate directory
42
41
  // scans so unrelated `*.log` files are never mistaken for a session.
43
42
  const SESSION_LOG_UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
43
+ const DEFAULT_LOG_SCAN_CHUNK_BYTES = 262144;
44
+ const LOG_SCAN_OVERLAP_BYTES = 1024;
44
45
 
45
46
  /**
46
47
  * Extract every tool session id printed to a log, in the order they appear.
@@ -79,34 +80,52 @@ export function selectLastSessionId(text) {
79
80
  }
80
81
 
81
82
  /**
82
- * Read the LAST tool session id from a `/solve` execution log. Only the tail of
83
- * the file is scanned (the most recent session marker lives near the end), so
84
- * this stays cheap on multi-megabyte logs. Never throws a missing/unreadable
85
- * log yields `null`.
83
+ * Read the LAST tool session id from a `/solve` execution log. The file is
84
+ * scanned backwards in bounded chunks: this normally stops after the tail
85
+ * chunk, while still finding a valid marker that precedes a long tool trace.
86
+ * Adjacent chunks overlap so a marker split at a chunk boundary is not lost.
87
+ * Never throws — a missing/unreadable log yields `null`.
86
88
  *
87
89
  * @param {string} logPath
88
90
  * @param {Object} [options]
89
91
  * @param {Object} [options.fsImpl=fs] - Injectable fs (for tests)
90
- * @param {number} [options.tailBytes=262144] - Trailing bytes to scan (256 KiB)
92
+ * @param {number} [options.tailBytes=262144] - Bytes per backwards scan chunk
91
93
  * @param {boolean} [options.verbose]
92
94
  * @returns {string|null}
93
95
  */
94
96
  export function readLastSessionIdFromLog(logPath, options = {}) {
95
- const { fsImpl = fs, tailBytes = 262144, verbose = false } = options;
97
+ const { fsImpl = fs, tailBytes = DEFAULT_LOG_SCAN_CHUNK_BYTES, verbose = false } = options;
96
98
  if (!logPath) return null;
97
99
  try {
98
100
  const stat = fsImpl.statSync(logPath);
99
- const start = Math.max(0, stat.size - tailBytes);
101
+ const chunkBytes = Number.isFinite(tailBytes) && tailBytes > 0 ? Math.floor(tailBytes) : DEFAULT_LOG_SCAN_CHUNK_BYTES;
100
102
  const fd = fsImpl.openSync(logPath, 'r');
101
103
  try {
102
- const length = stat.size - start;
103
- const buffer = Buffer.alloc(length);
104
- fsImpl.readSync(fd, buffer, 0, length, start);
105
- const id = selectLastSessionId(buffer.toString('utf8'));
106
- if (verbose && id) {
107
- console.log(`[VERBOSE] session-resume: last tool session id in ${logPath} is ${id}`);
104
+ let end = stat.size;
105
+ let laterPrefix = Buffer.alloc(0);
106
+ let chunksScanned = 0;
107
+ while (end > 0) {
108
+ const start = Math.max(0, end - chunkBytes);
109
+ const length = end - start;
110
+ const buffer = Buffer.alloc(length);
111
+ const bytesRead = fsImpl.readSync(fd, buffer, 0, length, start);
112
+ const current = bytesRead === length ? buffer : buffer.subarray(0, bytesRead);
113
+ const scanBuffer = laterPrefix.length > 0 ? Buffer.concat([current, laterPrefix]) : current;
114
+ const id = selectLastSessionId(scanBuffer.toString('utf8'));
115
+ chunksScanned += 1;
116
+ if (id) {
117
+ if (verbose) {
118
+ console.log(`[VERBOSE] session-resume: last tool session id in ${logPath} is ${id} (scanned ${chunksScanned} chunk${chunksScanned === 1 ? '' : 's'})`);
119
+ }
120
+ return id;
121
+ }
122
+ laterPrefix = current.subarray(0, Math.min(current.length, LOG_SCAN_OVERLAP_BYTES));
123
+ end = start;
108
124
  }
109
- return id;
125
+ if (verbose) {
126
+ console.log(`[VERBOSE] session-resume: no tool session id found in ${logPath} after scanning ${chunksScanned} chunk${chunksScanned === 1 ? '' : 's'}`);
127
+ }
128
+ return null;
110
129
  } finally {
111
130
  fsImpl.closeSync(fd);
112
131
  }
@@ -121,10 +140,10 @@ export function readLastSessionIdFromLog(logPath, options = {}) {
121
140
  /**
122
141
  * Find the id of the most-recently-modified `<sessionId>.log` in a directory.
123
142
  *
124
- * start-command renames each tool session's log to `<sessionId>.log`, so the
125
- * newest such file is the last session of the run — a second, filesystem-based
126
- * source for the "use the last session" rule that works even when the captured
127
- * stdout log has been rotated away. Never throws.
143
+ * This helper is safe only when `dir` is already known to contain logs for one
144
+ * task. A shared start-command directory cannot attribute its newest UUID log
145
+ * to a particular `/solve` run, so completion notifications must not use this
146
+ * as a fallback. Never throws.
128
147
  *
129
148
  * @param {Object} options
130
149
  * @param {string} options.dir - Directory holding `<sessionId>.log` files
@@ -215,7 +234,8 @@ export function buildResumeCommand({ sessionInfo = {}, lastSessionId = null, bin
215
234
  const url = sessionInfo.url || (Array.isArray(sessionInfo.args) ? sessionInfo.args[0] : null);
216
235
  if (!url) return null;
217
236
 
218
- const bin = binary || command;
237
+ const commandAlias = typeof sessionInfo.commandAlias === 'string' && /^[a-z0-9_-]+$/i.test(sessionInfo.commandAlias) ? sessionInfo.commandAlias : null;
238
+ const bin = binary || (commandAlias ? `/${commandAlias}` : command);
219
239
  let args;
220
240
  if (Array.isArray(sessionInfo.args) && sessionInfo.args.length > 0) {
221
241
  args = stripResumeFlag(sessionInfo.args);
@@ -33,7 +33,9 @@ import path from 'node:path';
33
33
  // excluded so the snapshot stays small and safe to reload.
34
34
  // `args` (#1927 review follow-up) is persisted so a killed /solve can be resumed
35
35
  // with its exact original invocation plus `--resume <lastSessionId>`.
36
- const PERSISTABLE_FIELDS = ['chatId', 'messageId', 'startTime', 'url', 'command', 'isolationBackend', 'sessionId', 'containerFilesystemStartBytes', 'containerFilesystemLastBytes', 'containerFilesystemLastObservedAt', 'tool', 'infoBlock', 'urlContext', 'requesterUserId', 'showLimits', 'locale', 'logPath', 'args'];
36
+ // `commandAlias` (#2109) preserves the Telegram spelling (`solve`, `codex`,
37
+ // `claude`, etc.) so a bot notification never suggests a terminal-only command.
38
+ const PERSISTABLE_FIELDS = ['chatId', 'messageId', 'startTime', 'url', 'command', 'commandAlias', 'isolationBackend', 'sessionId', 'containerFilesystemStartBytes', 'containerFilesystemLastBytes', 'containerFilesystemLastObservedAt', 'tool', 'infoBlock', 'urlContext', 'requesterUserId', 'showLimits', 'locale', 'logPath', 'args'];
37
39
 
38
40
  /**
39
41
  * Resolve the directory durable bot state is written to. Honors
@@ -11,6 +11,7 @@ import { handleCompareApiNotReady } from './solve.auto-pr-compare-readiness.lib.
11
11
 
12
12
  import { wrapDollarWithGhRetry as _wrapDollarWithGhRetry, execGhWithRetry, isTransientCompareApiError } from './github-rate-limit.lib.mjs'; // rate-limit marker (#1726): gh API calls flow through $ wrapped by caller. Issue #1756: execGhWithRetry retries on transient 5xx (504) too. Issue #1829: isTransientCompareApiError lets the compare-API readiness gate degrade gracefully on transient diff-render failures.
13
13
  import { stagePlaceholderFileOrExplain, explainNothingStagedAndThrow } from './solve.auto-pr-placeholder.lib.mjs'; // Issue #1825: handles the seed placeholder when the target repo gitignores it.
14
+ import { sanitizeForPublication, writeSanitizedPublicationFile } from './token-sanitization.lib.mjs';
14
15
 
15
16
  export async function handleAutoPrCreation({ argv, tempDir, branchName, issueNumber, owner, repo, defaultBranch, forkedRepo, isContinueMode, prNumber, log, formatAligned, $, reportError, path, fs }) {
16
17
  // Skip auto-PR creation if:
@@ -906,17 +907,19 @@ ${prBody}`,
906
907
  // single transient 5xx (e.g. `HTTP 504: 504 Gateway Timeout
907
908
  // (https://api.github.com/graphql)`) or rate-limit response retries
908
909
  // instead of aborting the whole solve session.
910
+ let prBodyFile = null;
911
+ let prTitleFile = null;
909
912
  try {
910
913
  // Write PR body to temp file to avoid shell escaping issues
911
- const prBodyFile = `/tmp/pr-body-${Date.now()}.md`;
912
- await fs.writeFile(prBodyFile, prBody);
914
+ prBodyFile = `/tmp/pr-body-${Date.now()}.md`;
915
+ await writeSanitizedPublicationFile(prBodyFile, prBody);
913
916
 
914
917
  // Write PR title to temp file to avoid shell escaping issues with quotes/apostrophes
915
918
  // This solves the issue where titles containing apostrophes (e.g., "don't") would cause
916
919
  // "Unterminated quoted string" errors
917
- const prTitle = `[WIP] ${issueTitle}`;
918
- const prTitleFile = `/tmp/pr-title-${Date.now()}.txt`;
919
- await fs.writeFile(prTitleFile, prTitle);
920
+ const prTitle = await sanitizeForPublication(`[WIP] ${issueTitle}`);
921
+ prTitleFile = `/tmp/pr-title-${Date.now()}.txt`;
922
+ await writeSanitizedPublicationFile(prTitleFile, prTitle);
920
923
 
921
924
  // Build command with optional assignee and handle forks
922
925
  // Note: targetBranch is already defined above
@@ -994,22 +997,6 @@ ${prBody}`,
994
997
  }
995
998
  }
996
999
 
997
- // Clean up temp files
998
- await fs.unlink(prBodyFile).catch(unlinkError => {
999
- reportError(unlinkError, {
1000
- context: 'pr_body_file_cleanup',
1001
- prBodyFile,
1002
- operation: 'delete_temp_file',
1003
- });
1004
- });
1005
- await fs.unlink(prTitleFile).catch(unlinkError => {
1006
- reportError(unlinkError, {
1007
- context: 'pr_title_file_cleanup',
1008
- prTitleFile,
1009
- operation: 'delete_temp_file',
1010
- });
1011
- });
1012
-
1013
1000
  // Log gh pr create output for debugging (Issue #1462)
1014
1001
  if (argv.verbose) {
1015
1002
  await log(` gh pr create stdout: ${(output || '').trim() || '(empty)'}`, { verbose: true });
@@ -1279,6 +1266,9 @@ ${prBody}`,
1279
1266
  } else {
1280
1267
  throw new Error(`PR creation failed: ${cleanError}`, { cause: prCreateError });
1281
1268
  }
1269
+ } finally {
1270
+ if (prBodyFile) await fs.unlink(prBodyFile).catch(() => {});
1271
+ if (prTitleFile) await fs.unlink(prTitleFile).catch(() => {});
1282
1272
  }
1283
1273
  }
1284
1274
  }
@@ -11,6 +11,7 @@ import { reportError } from './sentry.lib.mjs';
11
11
 
12
12
  // Import GitHub error reporter
13
13
  import { handleErrorWithIssueCreation } from './github-error-reporter.lib.mjs';
14
+ import { sanitizeForPublication } from './token-sanitization.lib.mjs';
14
15
 
15
16
  export const isErrorIssueAutoCreationDisabled = argv => !!(argv?.disableReportIssue || argv?.disableIssueAutoCreationOnError);
16
17
 
@@ -115,7 +116,7 @@ export const handleFailure = async options => {
115
116
  if (argv.autoClosePullRequestOnFail && global.createdPR && global.createdPR.number) {
116
117
  await log('\n🔒 Auto-closing pull request due to failure...');
117
118
  try {
118
- const closeMessage = errorType === 'uncaughtException' ? 'Auto-closed due to uncaught exception. Logs have been attached for debugging.' : errorType === 'unhandledRejection' ? 'Auto-closed due to unhandled rejection. Logs have been attached for debugging.' : 'Auto-closed due to execution failure. Logs have been attached for debugging.';
119
+ const closeMessage = await sanitizeForPublication(errorType === 'uncaughtException' ? 'Auto-closed due to uncaught exception. Logs have been attached for debugging.' : errorType === 'unhandledRejection' ? 'Auto-closed due to unhandled rejection. Logs have been attached for debugging.' : 'Auto-closed due to execution failure. Logs have been attached for debugging.');
119
120
 
120
121
  const result = await $`gh pr close ${global.createdPR.number} --repo ${global.owner || owner}/${global.repo || repo} --comment ${closeMessage}`;
121
122
  if (result.exitCode === 0) {
@@ -27,6 +27,7 @@
27
27
  // Issue #1625: centralized markers + tracking helpers so the live-progress
28
28
  // comment is excluded from --auto-attach-solution-summary's AI-comment check.
29
29
  import { LIVE_PROGRESS_SECTION_START_MARKER, LIVE_PROGRESS_SECTION_END_MARKER, postTrackedCommentFromFile, trackToolCommentId } from './tool-comments.lib.mjs';
30
+ import { writeSanitizedPublicationFile } from './token-sanitization.lib.mjs';
30
31
 
31
32
  import { wrapDollarWithGhRetry as _wrapDollarWithGhRetry } from './github-rate-limit.lib.mjs'; // rate-limit marker (#1726): gh API calls flow through $ wrapped by caller
32
33
  /**
@@ -225,9 +226,12 @@ export const createProgressMonitor = ({ owner, repo, prNumber, $, log, verbose =
225
226
  // Edit existing comment
226
227
  const fs = (await import('fs')).promises;
227
228
  const tempFile = `/tmp/pr-progress-comment-${prNumber}-${Date.now()}.md`;
228
- await fs.writeFile(tempFile, progressSection);
229
- await $`gh api repos/${owner}/${repo}/issues/comments/${state.commentId} --method PATCH --field body=@${tempFile}`;
230
- await fs.unlink(tempFile).catch(() => {});
229
+ await writeSanitizedPublicationFile(tempFile, progressSection);
230
+ try {
231
+ await $`gh api repos/${owner}/${repo}/issues/comments/${state.commentId} --method PATCH --field body=@${tempFile}`;
232
+ } finally {
233
+ await fs.unlink(tempFile).catch(() => {});
234
+ }
231
235
  } else {
232
236
  // Create new comment. Issue #1625: post via postTrackedCommentFromFile
233
237
  // so the comment ID is captured directly from the GitHub API response
@@ -235,9 +239,13 @@ export const createProgressMonitor = ({ owner, repo, prNumber, $, log, verbose =
235
239
  // posted comments from the "did the AI post anything?" check).
236
240
  const fs = (await import('fs')).promises;
237
241
  const tempFile = `/tmp/pr-progress-comment-${prNumber}-${Date.now()}.md`;
238
- await fs.writeFile(tempFile, progressSection);
239
- const posted = await postTrackedCommentFromFile({ $, owner, repo, targetNumber: prNumber, bodyFile: tempFile });
240
- await fs.unlink(tempFile).catch(() => {});
242
+ await writeSanitizedPublicationFile(tempFile, progressSection);
243
+ let posted;
244
+ try {
245
+ posted = await postTrackedCommentFromFile({ $, owner, repo, targetNumber: prNumber, bodyFile: tempFile });
246
+ } finally {
247
+ await fs.unlink(tempFile).catch(() => {});
248
+ }
241
249
 
242
250
  if (posted.ok && posted.commentId) {
243
251
  state.commentId = posted.commentId;
@@ -300,9 +308,12 @@ export const createProgressMonitor = ({ owner, repo, prNumber, $, log, verbose =
300
308
  // Write to temp file and update PR
301
309
  const fs = (await import('fs')).promises;
302
310
  const tempBodyFile = `/tmp/pr-progress-${prNumber}-${Date.now()}.md`;
303
- await fs.writeFile(tempBodyFile, updatedBody);
304
- await $`gh pr edit ${prNumber} --repo ${owner}/${repo} --body-file ${tempBodyFile}`;
305
- await fs.unlink(tempBodyFile).catch(() => {});
311
+ await writeSanitizedPublicationFile(tempBodyFile, updatedBody);
312
+ try {
313
+ await $`gh pr edit ${prNumber} --repo ${owner}/${repo} --body-file ${tempBodyFile}`;
314
+ } finally {
315
+ await fs.unlink(tempBodyFile).catch(() => {});
316
+ }
306
317
 
307
318
  const stats = calculateProgress(todos);
308
319
  await log(`📊 Updated PR progress: ${stats.percentage}% (${stats.completed}/${stats.total} tasks completed)`);
@@ -31,10 +31,10 @@ const { sanitizeLogContent, attachLogToGitHub } = githubLib;
31
31
 
32
32
  // Issue #1745: process-wide sanitization counters used to print a one-line
33
33
  // "we masked N secrets" summary at the end of each run.
34
- const { formatSanitizationSummary } = await import('./token-sanitization.lib.mjs');
34
+ const { formatSanitizationSummary, sanitizeForPublication, writeSanitizedPublicationFile } = await import('./token-sanitization.lib.mjs');
35
35
  // Issue #1745: post-finish retroactive sanitization of bot-authored PR
36
- // comments and the PR description. Runs by default; can be skipped via
37
- // --dangerously-skip-output-sanitization.
36
+ // comments and the PR description. This external repair boundary always runs
37
+ // when PR coordinates are available.
38
38
  const { runPostFinishSweep } = await import('./post-finish-sanitization-sweep.lib.mjs');
39
39
 
40
40
  // Import continuation functions (session resumption, PR detection)
@@ -153,7 +153,7 @@ export const ensurePullRequestIssueLink = async ({ prNumber, issueNumber, owner,
153
153
 
154
154
  const fs = (await use('fs')).promises;
155
155
  const tempBodyFile = `/tmp/pr-body-update-${prNumber}-${Date.now()}.md`;
156
- await fs.writeFile(tempBodyFile, linkResult.body);
156
+ await writeSanitizedPublicationFile(tempBodyFile, linkResult.body);
157
157
 
158
158
  try {
159
159
  const updateResult = await command`gh pr edit ${prNumber} --repo ${owner}/${repo} --body-file "${tempBodyFile}"`;
@@ -638,25 +638,19 @@ export const showSessionSummary = async (sessionId, limitReached, argv, issueUrl
638
638
  // Issue #1745: post-finish retroactive sanitization sweep. Re-reads
639
639
  // bot-authored PR comments and the PR description, runs them through
640
640
  // sanitizeOutput, and edits in place if a leak slipped past the live
641
- // sanitizer. Honors --dangerously-skip-output-sanitization and the related
642
- // active-tokens flag.
641
+ // sanitizer. Publication repair is a strict external boundary, so local
642
+ // diagnostic bypass flags never disable it.
643
643
  try {
644
644
  const owner = argv.owner;
645
645
  const repo = argv.repo;
646
646
  const prNumber = argv.prNumber;
647
- const skipOutputSanitization = argv['dangerously-skip-output-sanitization'] === true;
648
- const skipActiveTokensOutputSanitization = argv['dangerously-skip-active-tokens-output-sanitization'] === true;
649
- if (owner && repo && prNumber && !skipOutputSanitization) {
647
+ if (owner && repo && prNumber) {
650
648
  const sweepResult = await runPostFinishSweep({
651
649
  $,
652
650
  owner,
653
651
  repo,
654
652
  prNumber,
655
653
  log,
656
- sanitizationOptions: {
657
- warnOnMismatch: false,
658
- skipActiveTokensOutputSanitization,
659
- },
660
654
  });
661
655
  if (sweepResult.totalEdited > 0) {
662
656
  await log(`🔒 Post-finish sweep: edited ${sweepResult.totalEdited} bot-authored item(s) to mask leaked tokens.`);
@@ -771,7 +765,7 @@ export const verifyResults = async (owner, repo, branchName, issueNumber, prNumb
771
765
  // Skip cleanup if auto-restart-on-non-updated-pull-request-description is enabled
772
766
  // (let the agent handle it on restart instead)
773
767
  if (prTitleHasPlaceholder && !argv.autoRestartOnNonUpdatedPullRequestDescription) {
774
- const updatedTitle = pr.title.replace(/^\[WIP\]\s*/, '');
768
+ const updatedTitle = await sanitizeForPublication(pr.title.replace(/^\[WIP\]\s*/, ''));
775
769
  await log(` 📝 Removing [WIP] prefix from PR title...`);
776
770
  const titleResult = await $`gh pr edit ${pr.number} --repo ${owner}/${repo} --title "${updatedTitle}"`;
777
771
  if (titleResult.code === 0) {
@@ -819,7 +813,7 @@ Fixes ${issueRef}
819
813
  *This PR was created automatically by the AI issue solver*`;
820
814
 
821
815
  const tempBodyFile = `/tmp/pr-body-finalize-${pr.number}-${Date.now()}.md`;
822
- await fs.writeFile(tempBodyFile, newDescription);
816
+ await writeSanitizedPublicationFile(tempBodyFile, newDescription);
823
817
 
824
818
  try {
825
819
  const descResult = await $`gh pr edit ${pr.number} --repo ${owner}/${repo} --body-file "${tempBodyFile}"`;
@@ -4,6 +4,9 @@
4
4
  import { exec } from 'child_process';
5
5
  import { promisify } from 'util';
6
6
  import { parseCliArgumentsWithLino } from './cli-arguments.lib.mjs';
7
+ import { setupStdioLogInterceptor } from './lib.mjs';
8
+
9
+ setupStdioLogInterceptor();
7
10
 
8
11
  const execAsync = promisify(exec);
9
12
 
@@ -3,6 +3,7 @@ import path from 'path';
3
3
  import { spawn } from 'child_process';
4
4
  import { promises as fs } from 'fs';
5
5
  import { parseGitHubUrl } from './github.lib.mjs';
6
+ import { sanitizeForPublication, writeSanitizedPublicationFile } from './token-sanitization.lib.mjs';
6
7
 
7
8
  export const TASK_ISSUE_TITLE_MAX_LENGTH = 256;
8
9
 
@@ -216,9 +217,10 @@ export async function createTaskIssue({ repository, title, body, issueType = nul
216
217
  const bodyFile = path.join(tempDir, 'body.md');
217
218
 
218
219
  try {
219
- await fs.writeFile(bodyFile, body);
220
+ await writeSanitizedPublicationFile(bodyFile, body);
221
+ const sanitizedTitle = await sanitizeForPublication(title);
220
222
 
221
- const result = await run('gh', buildCreateIssueArgs({ repository, title, bodyFile, issueType, labels }));
223
+ const result = await run('gh', buildCreateIssueArgs({ repository, title: sanitizedTitle, bodyFile, issueType, labels }));
222
224
  if (result.code === 0) return parseCreatedTaskIssueOutput(result.stdout);
223
225
 
224
226
  const output = `${result.stderr || ''}${result.stdout || ''}`.trim();
@@ -228,7 +230,7 @@ export async function createTaskIssue({ repository, title, body, issueType = nul
228
230
  }
229
231
 
230
232
  await log?.(`⚠️ Could not create issue with type/labels (${output || `exit code ${result.code}`}); retrying without them`);
231
- const retry = await run('gh', buildCreateIssueArgs({ repository, title, bodyFile }));
233
+ const retry = await run('gh', buildCreateIssueArgs({ repository, title: sanitizedTitle, bodyFile }));
232
234
  if (retry.code !== 0) {
233
235
  const retryOutput = `${retry.stderr || ''}${retry.stdout || ''}`.trim();
234
236
  throw new Error(retryOutput || `gh issue create exited with code ${retry.code}`);
package/src/task.mjs CHANGED
@@ -8,6 +8,11 @@ import { buildStartAgentArgs, resolveStartAgentCommand } from './task.agent-comm
8
8
  import { getDefaultTaskModel, parseTaskArguments } from './task.config.lib.mjs';
9
9
  import { validateModelName } from './models/index.mjs';
10
10
  import { appendOrReplaceParentSplitSection, buildAddSubIssueApiArgs, buildIssueRestIdApiArgs, buildTaskSplitPrompt, buildTaskSplitSystemPrompt, extractTaskSplitJson, formatChildIssueBody, normalizeSplitTasks, parseCreatedIssueUrl, parseTaskIssueUrl } from './task.split.lib.mjs';
11
+ import { setupStdioLogInterceptor } from './lib.mjs';
12
+ import { sanitizeCredentialText } from './credential-sanitization-core.lib.mjs';
13
+ import { sanitizeForPublication } from './token-sanitization.lib.mjs';
14
+
15
+ setupStdioLogInterceptor();
11
16
 
12
17
  const earlyArgs = process.argv.slice(2);
13
18
 
@@ -73,10 +78,14 @@ const logFile = path.join(scriptDir, `task-${timestamp}.log`);
73
78
  async function log(message, options = {}) {
74
79
  const { level = 'info', verbose = false } = options;
75
80
  if (verbose && !argv.verbose) return;
76
- await fs.appendFile(logFile, `[${new Date().toISOString()}] [${level.toUpperCase()}] ${message}\n`).catch(() => {});
77
- if (level === 'error') console.error(message);
78
- else if (level === 'warning' || level === 'warn') console.warn(message);
79
- else console.log(message);
81
+ const sanitizedMessage = sanitizeCredentialText(message);
82
+ await fs
83
+ .appendFile(logFile, `[${new Date().toISOString()}] [${level.toUpperCase()}] ${sanitizedMessage}\n`, { mode: 0o600 })
84
+ .then(() => fs.chmod(logFile, 0o600))
85
+ .catch(() => {});
86
+ if (level === 'error') console.error(sanitizedMessage);
87
+ else if (level === 'warning' || level === 'warn') console.warn(sanitizedMessage);
88
+ else console.log(sanitizedMessage);
80
89
  }
81
90
 
82
91
  function formatAligned(icon, label, value, indent = 0) {
@@ -172,7 +181,8 @@ async function fetchIssueRestId(issue) {
172
181
  }
173
182
 
174
183
  async function createChildIssue(parentIssue, task, index, splitCount) {
175
- const args = ['issue', 'create', '--repo', `${parentIssue.owner}/${parentIssue.repo}`, '--title', task.title, '--body', formatChildIssueBody({ parentIssue, task, index, splitCount })];
184
+ const [safeTitle, safeBody] = await Promise.all([sanitizeForPublication(task.title), sanitizeForPublication(formatChildIssueBody({ parentIssue, task, index, splitCount }))]);
185
+ const args = ['issue', 'create', '--repo', `${parentIssue.owner}/${parentIssue.repo}`, '--title', safeTitle, '--body', safeBody];
176
186
  if (parentIssue.labels.length > 0) {
177
187
  args.push('--label', parentIssue.labels.join(','));
178
188
  }
@@ -196,9 +206,11 @@ async function linkChildIssue(parentIssue, childIssue) {
196
206
 
197
207
  async function updateParentIssue(parentIssue, childIssues) {
198
208
  const body = appendOrReplaceParentSplitSection(parentIssue.body, childIssues);
199
- await commandOutput('gh', ['issue', 'edit', String(parentIssue.number), '--repo', `${parentIssue.owner}/${parentIssue.repo}`, '--body', body]);
209
+ const safeBody = await sanitizeForPublication(body);
210
+ await commandOutput('gh', ['issue', 'edit', String(parentIssue.number), '--repo', `${parentIssue.owner}/${parentIssue.repo}`, '--body', safeBody]);
200
211
  const childList = childIssues.map(issue => `- #${issue.number} ${issue.title}`).join('\n');
201
- await commandOutput('gh', ['issue', 'comment', String(parentIssue.number), '--repo', `${parentIssue.owner}/${parentIssue.repo}`, '--body', `Split into ${childIssues.length} tasks:\n\n${childList}`]);
212
+ const safeComment = await sanitizeForPublication(`Split into ${childIssues.length} tasks:\n\n${childList}`);
213
+ await commandOutput('gh', ['issue', 'comment', String(parentIssue.number), '--repo', `${parentIssue.owner}/${parentIssue.repo}`, '--body', safeComment]);
202
214
  }
203
215
 
204
216
  async function runSplitMode() {
@@ -268,7 +280,8 @@ ${results.clarification ? `Clarification analysis:\n${results.clarification}\n\n
268
280
  }
269
281
 
270
282
  try {
271
- await fs.writeFile(logFile, `# Task Log - ${new Date().toISOString()}\n\n`);
283
+ await fs.writeFile(logFile, `# Task Log - ${new Date().toISOString()}\n\n`, { mode: 0o600 });
284
+ await fs.chmod(logFile, 0o600);
272
285
  await log(`📁 Log file: ${logFile}`);
273
286
  await log('\n🎯 Task Processing Started');
274
287
  await log(formatAligned('📝', 'Task input:', taskInput));
@@ -1,5 +1,8 @@
1
1
  #!/usr/bin/env node
2
2
  import { ensureUseM } from './use-m-bootstrap.lib.mjs';
3
+ import { maskToken, setupStdioLogInterceptor } from './lib.mjs';
4
+
5
+ setupStdioLogInterceptor();
3
6
  // Early exit for --version (issue #1318: avoid dotenvx MISSING_ENV_FILE warnings)
4
7
  if (process.argv.includes('--version')) {
5
8
  const v = await import('./version.lib.mjs').then(m => m.getVersion()).catch(() => 'unknown');
@@ -212,7 +215,7 @@ if (hiveEnabled && hiveOverrides.length > 0) {
212
215
  if (config.dryRun) {
213
216
  console.log('\n✅ Dry-run mode: All validations passed successfully!');
214
217
  console.log('\nConfiguration summary:');
215
- console.log(' Token:', BOT_TOKEN ? `${BOT_TOKEN.substring(0, 10)}...` : 'not set');
218
+ console.log(' Token:', BOT_TOKEN ? maskToken(BOT_TOKEN) : 'not set');
216
219
  if (allowedChats && allowedChats.length > 0) {
217
220
  console.log(' Allowed chats:', lino.format(allowedChats));
218
221
  } else {
@@ -820,13 +823,13 @@ async function handleSolveCommand(ctx) {
820
823
 
821
824
  if (check.canStart && check.startReserved) {
822
825
  const startingMessage = await safeReply(ctx, formatStartingWorkSessionMessage({ infoBlock, locale: solveLocale }), { reply_to_message_id: ctx.message.message_id });
823
- await executeAndUpdateMessage(ctx, startingMessage, 'solve', argsWithLocale, infoBlock, effectiveSolveIsolation, solveTool, solveUrlContext, { showLimits: solveShowLimits, limitsAtStart: solveLimitsAtStart, locale: solveLocale });
826
+ await executeAndUpdateMessage(ctx, startingMessage, 'solve', argsWithLocale, infoBlock, effectiveSolveIsolation, solveTool, solveUrlContext, { showLimits: solveShowLimits, limitsAtStart: solveLimitsAtStart, locale: solveLocale, commandAlias: solveCommandName });
824
827
  } else {
825
828
  if (!solveQueue.executeCallback) {
826
829
  const _t = (s, i) => trackSession(s, i, VERBOSE);
827
830
  solveQueue.executeCallback = createIsolationAwareQueueCallback(ISOLATION_BACKEND, isolationRunner, _t, createQueueExecuteCallback(executeStartScreen, _t), VERBOSE);
828
831
  }
829
- const queueItem = solveQueue.enqueue({ url: normalizedUrl, args: argsWithLocale, ctx, requester, infoBlock, tool: solveTool, perCommandIsolation: effectiveSolveIsolation, urlContext: solveUrlContext, showLimits: solveShowLimits, limitsAtStart: solveLimitsAtStart, locale: solveLocale });
832
+ const queueItem = solveQueue.enqueue({ url: normalizedUrl, args: argsWithLocale, ctx, requester, infoBlock, commandAlias: solveCommandName, tool: solveTool, perCommandIsolation: effectiveSolveIsolation, urlContext: solveUrlContext, showLimits: solveShowLimits, limitsAtStart: solveLimitsAtStart, locale: solveLocale });
830
833
  const queueMessage = buildSolveQueuedMessage({ locale: solveLocale, tool: solveTool, position: toolQueuedCount + 1, infoBlock, reason: check.reason ? escapeMarkdown(check.reason) : '' }); // tool-specific position (#1551)
831
834
  const queuedMessage = await safeReply(ctx, queueMessage, { reply_to_message_id: ctx.message.message_id });
832
835
  queueItem.messageInfo = { chatId: queuedMessage.chat.id, messageId: queuedMessage.message_id };
@@ -99,7 +99,7 @@ function executeWithCommand(startScreenCmd, command, args, verbose = false) {
99
99
  */
100
100
  export function buildExecuteAndUpdateMessage(deps) {
101
101
  const { resolveIsolation, ISOLATION_BACKEND, isolationRunner, VERBOSE, executeStartScreen, trackSession, untrackSession, AUTO_WATCH_MESSAGE, startAutoTerminalWatchForSession, bot, formatExecutingWorkSessionMessage, formatStartingWorkSessionMessage } = deps;
102
- return async function executeAndUpdateMessage(ctx, startingMessage, commandName, args, infoBlock, perCommandIsolation = null, tool = 'claude', urlContext = null, { showLimits = false, limitsAtStart = null, locale = null } = {}) {
102
+ return async function executeAndUpdateMessage(ctx, startingMessage, commandName, args, infoBlock, perCommandIsolation = null, tool = 'claude', urlContext = null, { showLimits = false, limitsAtStart = null, locale = null, commandAlias = null } = {}) {
103
103
  const { chat, message_id: msgId } = startingMessage;
104
104
  const safeEdit = async text => {
105
105
  try {
@@ -111,7 +111,7 @@ export function buildExecuteAndUpdateMessage(deps) {
111
111
  const requesterUserId = ctx.from?.id ?? null; // Issue #1688: suppress duplicate /subscribe DM
112
112
  // #1927 review follow-up: persist the full args so a killed /solve can be
113
113
  // resumed with its exact original invocation + `--resume <lastSessionId>`.
114
- const baseSessionInfo = { chatId: ctx.chat.id, messageId: msgId, startTime: new Date(), url: args[0], command: commandName, tool, infoBlock, urlContext, requesterUserId, showLimits, limitsAtStart, locale, args: Array.isArray(args) ? [...args] : undefined }; // #594: showLimits/limitsAtStart
114
+ const baseSessionInfo = { chatId: ctx.chat.id, messageId: msgId, startTime: new Date(), url: args[0], command: commandName, commandAlias, tool, infoBlock, urlContext, requesterUserId, showLimits, limitsAtStart, locale, args: Array.isArray(args) ? [...args] : undefined }; // #594: showLimits/limitsAtStart
115
115
  const iso = await resolveIsolation(perCommandIsolation, ISOLATION_BACKEND, isolationRunner, VERBOSE);
116
116
  let result, session, sessionInfo;
117
117
  if (iso) {