@link-assistant/hive-mind 2.12.2 → 2.12.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/CHANGELOG.md +12 -0
- package/package.json +1 -1
- package/src/credential-sanitization-core.lib.mjs +104 -9
- package/src/encoded-credential-detection.lib.mjs +1009 -0
- package/src/formal-ai-image.lib.mjs +222 -0
- package/src/formal-ai-isolation.lib.mjs +8 -1
- package/src/formal-ai-sidecar.lib.mjs +36 -13
- package/src/formal-ai-updater.lib.mjs +14 -2
- package/src/hive-mind-image.lib.mjs +56 -0
- package/src/isolation-runner.lib.mjs +71 -54
- package/src/locales/en.lino +3 -0
- package/src/locales/hi.lino +3 -0
- package/src/locales/ru.lino +3 -0
- package/src/locales/zh.lino +3 -0
- package/src/session-kill-recovery.lib.mjs +9 -1
- package/src/session-monitor.lib.mjs +35 -4
- package/src/session-store.lib.mjs +7 -2
- package/src/telegram-command-execution.lib.mjs +26 -7
- package/src/telegram-solve-queue.lib.mjs +30 -5
- package/src/token-sanitization.lib.mjs +182 -5
- package/src/work-session-formatting.lib.mjs +56 -3
package/src/locales/hi.lino
CHANGED
|
@@ -654,6 +654,7 @@ hi
|
|
|
654
654
|
runner_also_failed "रनर भी विफल हुआ; जाँच के लिए उसका exit code सुरक्षित रखा गया है।"
|
|
655
655
|
killed "कार्य सत्र रोका गया: {{reason}}{{exitSuffix}}"
|
|
656
656
|
stopped "कार्य सत्र उपयोगकर्ता द्वारा रोका गया{{requestedBy}}{{exitSuffix}}"
|
|
657
|
+
not_launched "कार्य सत्र शुरू ही नहीं हुआ, इसलिए उसका कोई log नहीं है और वह `--list` में नहीं दिखता।"
|
|
657
658
|
duration
|
|
658
659
|
label "अवधि"
|
|
659
660
|
session
|
|
@@ -670,6 +671,8 @@ hi
|
|
|
670
671
|
resumed_attempt "🔄 इस समाप्ति से पुनर्प्राप्ति हेतु नया कार्य सत्र शुरू किया गया (प्रयास {{attempt}}): {{sessionId}}"
|
|
671
672
|
isolation
|
|
672
673
|
label "Isolation"
|
|
674
|
+
execution
|
|
675
|
+
label "निष्पादन"
|
|
673
676
|
error
|
|
674
677
|
executing
|
|
675
678
|
command "❌ {{commandName}} command चलाने में त्रुटि"
|
package/src/locales/ru.lino
CHANGED
|
@@ -654,6 +654,7 @@ ru
|
|
|
654
654
|
runner_also_failed "Средство запуска также завершилось с ошибкой; код выхода сохранён для расследования."
|
|
655
655
|
killed "Рабочий сеанс остановлен: {{reason}}{{exitSuffix}}"
|
|
656
656
|
stopped "Рабочий сеанс остановлен пользователем{{requestedBy}}{{exitSuffix}}"
|
|
657
|
+
not_launched "Рабочий сеанс не был запущен, поэтому у него нет журнала и он не отображается в `--list`."
|
|
657
658
|
duration
|
|
658
659
|
label "Длительность"
|
|
659
660
|
session
|
|
@@ -670,6 +671,8 @@ ru
|
|
|
670
671
|
resumed_attempt "🔄 Запущена новая рабочая сессия для восстановления после этого завершения (попытка {{attempt}}): {{sessionId}}"
|
|
671
672
|
isolation
|
|
672
673
|
label "Изоляция"
|
|
674
|
+
execution
|
|
675
|
+
label "Запуск"
|
|
673
676
|
error
|
|
674
677
|
executing
|
|
675
678
|
command "❌ Ошибка выполнения команды {{commandName}}"
|
package/src/locales/zh.lino
CHANGED
|
@@ -654,6 +654,7 @@ zh
|
|
|
654
654
|
runner_also_failed "运行器也失败了;其退出代码已保留以供调查。"
|
|
655
655
|
killed "工作会话已终止:{{reason}}{{exitSuffix}}"
|
|
656
656
|
stopped "工作会话已由用户停止{{requestedBy}}{{exitSuffix}}"
|
|
657
|
+
not_launched "工作会话未启动,因此没有日志,也不会出现在 `--list` 中。"
|
|
657
658
|
duration
|
|
658
659
|
label "耗时"
|
|
659
660
|
session
|
|
@@ -670,6 +671,8 @@ zh
|
|
|
670
671
|
resumed_attempt "🔄 已启动新的工作会话以从此次终止中恢复(第 {{attempt}} 次尝试):{{sessionId}}"
|
|
671
672
|
isolation
|
|
672
673
|
label "隔离"
|
|
674
|
+
execution
|
|
675
|
+
label "执行"
|
|
673
676
|
error
|
|
674
677
|
executing
|
|
675
678
|
command "❌ 执行 {{commandName}} 命令时出错"
|
|
@@ -24,6 +24,7 @@
|
|
|
24
24
|
|
|
25
25
|
import { spawn } from 'child_process';
|
|
26
26
|
import { describeChildExit } from './child-exit.lib.mjs';
|
|
27
|
+
import { sanitizeForPublication } from './token-sanitization.lib.mjs'; // issue #2156: this body is published to a pull request
|
|
27
28
|
import { KILL_CAUSE_DISK_FULL, KILL_CAUSE_FORCED_KILL, KILL_CAUSE_OUT_OF_MEMORY } from './session-kill-diagnostics.lib.mjs';
|
|
28
29
|
import { ON_SESSION_KILL_RESUME } from './session-kill-policy.lib.mjs';
|
|
29
30
|
|
|
@@ -161,6 +162,13 @@ const defaultUnlink = async filePath => {
|
|
|
161
162
|
* `--body-file` (not `--body`) is used deliberately: the notice contains
|
|
162
163
|
* backticks and newlines that would otherwise have to survive shell quoting.
|
|
163
164
|
*
|
|
165
|
+
* Issue #2156: the body is sanitized here rather than by the caller. It carries
|
|
166
|
+
* kill diagnostics and a resume command, both assembled from process and log
|
|
167
|
+
* data, so it is a publication boundary like any other and must fail closed.
|
|
168
|
+
* The array-argument `gh` invocation below is invisible to the
|
|
169
|
+
* `require-sanitized-output` ESLint rule, which is exactly how this path stayed
|
|
170
|
+
* unsanitized; the rule now understands this shape too.
|
|
171
|
+
*
|
|
164
172
|
* @param {Object} options
|
|
165
173
|
* @param {string} options.pullRequestUrl
|
|
166
174
|
* @param {string} options.body
|
|
@@ -178,7 +186,7 @@ export async function postKillRecoveryNotice({ pullRequestUrl, body, runCommand
|
|
|
178
186
|
|
|
179
187
|
const bodyFile = `${tempDir.replace(/\/$/, '')}/hive-mind-kill-notice-${fileSuffix}.md`;
|
|
180
188
|
try {
|
|
181
|
-
await writeFile(bodyFile, body);
|
|
189
|
+
await writeFile(bodyFile, await sanitizeForPublication(body));
|
|
182
190
|
const result = await runCommand('gh', ['pr', 'comment', pullRequestUrl, '--body-file', bodyFile]);
|
|
183
191
|
if (result?.code === 0) {
|
|
184
192
|
const url = String(result.stdout || '').trim() || null;
|
|
@@ -164,6 +164,9 @@ export function trackSession(sessionName, sessionInfo, verbose = false) {
|
|
|
164
164
|
url: sessionInfo.url || null,
|
|
165
165
|
command: sessionInfo.command || null,
|
|
166
166
|
sessionId: sessionInfo.sessionId || null,
|
|
167
|
+
// Issue #2154: `$ --list` prints start-command's execution UUID, not the
|
|
168
|
+
// session name. Logging both is what makes the two views joinable.
|
|
169
|
+
executionUuid: sessionInfo.executionUuid || null,
|
|
167
170
|
startTime: sessionInfo.startTime instanceof Date ? sessionInfo.startTime.toISOString() : sessionInfo.startTime || null,
|
|
168
171
|
});
|
|
169
172
|
}
|
|
@@ -230,24 +233,40 @@ export function markSessionStopRequested(sessionId, { requestedBy = null, verbos
|
|
|
230
233
|
* map and the durable store without emitting a `session_completed` audit event —
|
|
231
234
|
* the session never ran, so it has no exit code to record (issue #1946).
|
|
232
235
|
*
|
|
236
|
+
* Issue #2154: the durable structured log recorded only
|
|
237
|
+
* `session_untracked {"sessionName":…}`. Why the session disappeared a second
|
|
238
|
+
* after it was announced lived on untimestamped console lines, so an incident
|
|
239
|
+
* could not be reconstructed from the timestamped log alone. Callers now pass
|
|
240
|
+
* the reason and it is recorded with the event.
|
|
241
|
+
*
|
|
233
242
|
* @param {string} sessionName - Name/UUID of the session to drop
|
|
234
243
|
* @param {boolean} verbose - Whether to log verbose output
|
|
244
|
+
* @param {object} [details] - Extra context recorded with the `session_untracked` event
|
|
245
|
+
* @param {string} [details.reason] - Why the session was dropped (e.g. the launch error)
|
|
235
246
|
*/
|
|
236
|
-
export function untrackSession(sessionName, verbose = false) {
|
|
247
|
+
export function untrackSession(sessionName, verbose = false, details = {}) {
|
|
237
248
|
if (!sessionName) return;
|
|
238
249
|
const sessionInfo = activeSessions.get(sessionName) || null;
|
|
239
250
|
const existed = activeSessions.delete(sessionName);
|
|
251
|
+
const reason = typeof details?.reason === 'string' && details.reason.trim() ? details.reason.trim() : null;
|
|
240
252
|
if (verbose && existed) {
|
|
241
|
-
console.log(`[VERBOSE] Session ${sessionName} untracked (launch failed before it started)`);
|
|
253
|
+
console.log(`[VERBOSE] Session ${sessionName} untracked (launch failed before it started)${reason ? `: ${reason}` : ''}`);
|
|
242
254
|
}
|
|
243
255
|
if (sessionStore && isPersistableSession(sessionInfo)) {
|
|
244
256
|
try {
|
|
245
|
-
sessionStore.remove(sessionName, { status: 'launch-failed', exitCode: null });
|
|
257
|
+
sessionStore.remove(sessionName, { status: 'launch-failed', exitCode: null, reason });
|
|
246
258
|
} catch (error) {
|
|
247
259
|
console.error(`[session-monitor] Could not remove untracked session ${sessionName}: ${error.message}`);
|
|
248
260
|
}
|
|
249
261
|
}
|
|
250
|
-
logEvent('session_untracked', {
|
|
262
|
+
logEvent('session_untracked', {
|
|
263
|
+
sessionName,
|
|
264
|
+
reason,
|
|
265
|
+
url: sessionInfo?.url || null,
|
|
266
|
+
command: sessionInfo?.command || null,
|
|
267
|
+
tool: sessionInfo?.tool || null,
|
|
268
|
+
isolationBackend: sessionInfo?.isolationBackend || null,
|
|
269
|
+
});
|
|
251
270
|
}
|
|
252
271
|
/**
|
|
253
272
|
* Get the number of active sessions being tracked
|
|
@@ -646,6 +665,18 @@ export async function monitorSessions(bot, verbose = false, options = {}) {
|
|
|
646
665
|
sessionInfo.logPath = statusResult.logPath;
|
|
647
666
|
persistSessionSnapshot(sessionName, sessionInfo);
|
|
648
667
|
}
|
|
668
|
+
// Issue #2154: the same status record carries start-command's *execution*
|
|
669
|
+
// UUID — the only identifier `$ --list` prints. A session launched before
|
|
670
|
+
// this fix (or by a start-command whose banner we could not parse) has
|
|
671
|
+
// none, so backfill it here; otherwise that session stays impossible to
|
|
672
|
+
// find in the session list for its whole lifetime.
|
|
673
|
+
if (statusResult?.uuid && sessionInfo.executionUuid !== statusResult.uuid) {
|
|
674
|
+
if (verbose) {
|
|
675
|
+
console.log(`[VERBOSE] Session ${sessionName}: recorded start-command execution UUID ${statusResult.uuid} (this is what '$ --list' shows)`);
|
|
676
|
+
}
|
|
677
|
+
sessionInfo.executionUuid = statusResult.uuid;
|
|
678
|
+
persistSessionSnapshot(sessionName, sessionInfo);
|
|
679
|
+
}
|
|
649
680
|
} else {
|
|
650
681
|
// Issue #1586: Non-isolation screen sessions cannot reliably detect
|
|
651
682
|
// completion because start-screen keeps the screen alive via `exec bash`.
|
|
@@ -35,7 +35,10 @@ import path from 'node:path';
|
|
|
35
35
|
// with its exact original invocation plus `--resume <lastSessionId>`.
|
|
36
36
|
// `commandAlias` (#2109) preserves the Telegram spelling (`solve`, `codex`,
|
|
37
37
|
// `claude`, etc.) so a bot notification never suggests a terminal-only command.
|
|
38
|
-
|
|
38
|
+
// `executionUuid` (#2154) is start-command's own identifier for the execution —
|
|
39
|
+
// the one `$ --list` prints. It differs from `sessionId`, so persisting it is
|
|
40
|
+
// what lets a restarted bot still correlate its sessions with the session list.
|
|
41
|
+
const PERSISTABLE_FIELDS = ['chatId', 'messageId', 'startTime', 'url', 'command', 'commandAlias', 'isolationBackend', 'sessionId', 'executionUuid', 'containerFilesystemStartBytes', 'containerFilesystemLastBytes', 'containerFilesystemLastObservedAt', 'tool', 'infoBlock', 'urlContext', 'requesterUserId', 'showLimits', 'locale', 'logPath', 'args'];
|
|
39
42
|
|
|
40
43
|
/**
|
|
41
44
|
* Resolve the directory durable bot state is written to. Honors
|
|
@@ -213,7 +216,9 @@ export function createSessionStore(options = {}) {
|
|
|
213
216
|
delete sessions[sessionName];
|
|
214
217
|
writeSnapshotMap(sessions);
|
|
215
218
|
}
|
|
216
|
-
|
|
219
|
+
// Issue #2154: carry the reason (when the caller knows it) so the durable
|
|
220
|
+
// event log says why a session ended, not just that it did.
|
|
221
|
+
appendEvent('complete', sessionName, { status: meta.status ?? null, exitCode: meta.exitCode ?? null, reason: meta.reason ?? null });
|
|
217
222
|
log('debug', `Removed session ${sessionName} from snapshot`, meta);
|
|
218
223
|
},
|
|
219
224
|
|
|
@@ -2,7 +2,7 @@ import { spawn } from 'child_process';
|
|
|
2
2
|
import { describeChildExit } from './child-exit.lib.mjs';
|
|
3
3
|
import { promisify } from 'util';
|
|
4
4
|
import { exec as execCallback } from 'child_process';
|
|
5
|
-
import {
|
|
5
|
+
import { formatFailedLaunchMessage as defaultFormatFailedLaunchMessage } from './work-session-formatting.lib.mjs';
|
|
6
6
|
|
|
7
7
|
const exec = promisify(execCallback);
|
|
8
8
|
|
|
@@ -101,7 +101,7 @@ function executeWithCommand(startScreenCmd, command, args, verbose = false) {
|
|
|
101
101
|
* @returns {Function} executeAndUpdateMessage(ctx, startingMessage, commandName, args, infoBlock, perCommandIsolation, tool, urlContext, sessionExtras)
|
|
102
102
|
*/
|
|
103
103
|
export function buildExecuteAndUpdateMessage(deps) {
|
|
104
|
-
const { resolveIsolation, ISOLATION_BACKEND, isolationRunner, VERBOSE, executeStartScreen, trackSession, untrackSession, AUTO_WATCH_MESSAGE, startAutoTerminalWatchForSession, bot, formatExecutingWorkSessionMessage, formatStartingWorkSessionMessage } = deps;
|
|
104
|
+
const { resolveIsolation, ISOLATION_BACKEND, isolationRunner, VERBOSE, executeStartScreen, trackSession, untrackSession, AUTO_WATCH_MESSAGE, startAutoTerminalWatchForSession, bot, formatExecutingWorkSessionMessage, formatStartingWorkSessionMessage, formatFailedLaunchMessage = defaultFormatFailedLaunchMessage } = deps;
|
|
105
105
|
return async function executeAndUpdateMessage(ctx, startingMessage, commandName, args, infoBlock, perCommandIsolation = null, tool = 'claude', urlContext = null, { showLimits = false, limitsAtStart = null, locale = null, commandAlias = null } = {}) {
|
|
106
106
|
const { chat, message_id: msgId } = startingMessage;
|
|
107
107
|
const safeEdit = async text => {
|
|
@@ -130,14 +130,27 @@ export function buildExecuteAndUpdateMessage(deps) {
|
|
|
130
130
|
trackSession(session, sessionInfo, VERBOSE);
|
|
131
131
|
await safeEdit(formatStartingWorkSessionMessage({ sessionName: session, isolationBackend: iso.backend, infoBlock, locale }));
|
|
132
132
|
result = await iso.runner.executeWithIsolation(commandName, args, { backend: iso.backend, sessionId: session, tool, verbose: VERBOSE });
|
|
133
|
-
if (result.success && sessionInfo && Number.isFinite(result.containerFilesystemStartBytes)) {
|
|
134
|
-
sessionInfo.containerFilesystemStartBytes = result.containerFilesystemStartBytes;
|
|
133
|
+
if (result.success && sessionInfo && (Number.isFinite(result.containerFilesystemStartBytes) || result.executionUuid)) {
|
|
134
|
+
if (Number.isFinite(result.containerFilesystemStartBytes)) sessionInfo.containerFilesystemStartBytes = result.containerFilesystemStartBytes;
|
|
135
|
+
// Issue #2154: `$ --list` identifies executions by start-command's own
|
|
136
|
+
// UUID, not by the session name the bot shows. Keep both on the session
|
|
137
|
+
// so the two views can be joined — in the reply, in the structured log
|
|
138
|
+
// and in the durable snapshot after a restart.
|
|
139
|
+
if (result.executionUuid) sessionInfo.executionUuid = result.executionUuid;
|
|
135
140
|
trackSession(session, sessionInfo, VERBOSE);
|
|
136
141
|
}
|
|
137
142
|
if (!result.success) {
|
|
138
143
|
// The launch never produced a live container — drop the optimistic
|
|
139
144
|
// tracking so a phantom session is not monitored or resumed.
|
|
140
|
-
|
|
145
|
+
// Issue #2154: the untracking used to be the *only* trace of the
|
|
146
|
+
// failure anywhere outside Telegram. Record the UUID, the backend and
|
|
147
|
+
// the reason first, so the bot log explains why a session that was
|
|
148
|
+
// announced a second ago is gone and absent from `--list`.
|
|
149
|
+
const launchError = result.error || result.output || 'unknown error';
|
|
150
|
+
console.error(`[telegram-bot] ${commandName} session ${session} was not launched (isolation=${iso.backend}, tool=${tool}): ${launchError}`);
|
|
151
|
+
// The reason also goes into the structured `session_untracked` event, so
|
|
152
|
+
// the timestamped log explains the disappearance on its own.
|
|
153
|
+
if (typeof untrackSession === 'function') untrackSession(session, VERBOSE, { reason: launchError });
|
|
141
154
|
sessionInfo = undefined;
|
|
142
155
|
}
|
|
143
156
|
} else {
|
|
@@ -152,9 +165,15 @@ export function buildExecuteAndUpdateMessage(deps) {
|
|
|
152
165
|
}
|
|
153
166
|
if (result.warning) return safeEdit(`⚠️ ${result.warning}`);
|
|
154
167
|
if (result.success) {
|
|
155
|
-
await safeEdit(formatExecutingWorkSessionMessage({ sessionName: session, isolationBackend: iso?.backend || null, infoBlock, locale }));
|
|
168
|
+
await safeEdit(formatExecutingWorkSessionMessage({ sessionName: session, executionUuid: result.executionUuid || null, isolationBackend: iso?.backend || null, infoBlock, locale }));
|
|
156
169
|
if (AUTO_WATCH_MESSAGE && commandName === 'solve' && sessionInfo?.isolationBackend) await startAutoTerminalWatchForSession({ bot, ctx, sessionId: session, sessionInfo, verbose: VERBOSE });
|
|
157
|
-
} else
|
|
170
|
+
} else {
|
|
171
|
+
// Issue #2154: keep the session UUID in the failure reply. It is the only
|
|
172
|
+
// handle the operator has on the attempt, and this edit replaces the
|
|
173
|
+
// "🔄 Starting..." message that used to carry it.
|
|
174
|
+
if (!iso) console.error(`[telegram-bot] ${commandName} command failed to start (no isolation, tool=${tool}): ${result.error || result.output || 'unknown error'}`);
|
|
175
|
+
await safeEdit(formatFailedLaunchMessage({ commandName, sessionName: iso ? session : null, isolationBackend: iso?.backend || null, infoBlock, error: result.error || result.output, locale }));
|
|
176
|
+
}
|
|
158
177
|
};
|
|
159
178
|
}
|
|
160
179
|
|
|
@@ -15,7 +15,7 @@ import { collectExecutingItems, formatDuration, formatQueueToolSection, formatWa
|
|
|
15
15
|
export { QUEUE_CONFIG, THRESHOLD_STRATEGIES } from './queue-config.lib.mjs';
|
|
16
16
|
import { QUEUE_CONFIG } from './queue-config.lib.mjs';
|
|
17
17
|
import { reserveStartSlotForQueue } from './queue-start-reservation.lib.mjs';
|
|
18
|
-
import { formatExecutingWorkSessionMessage, formatStartingWorkSessionMessage } from './work-session-formatting.lib.mjs';
|
|
18
|
+
import { formatExecutingWorkSessionMessage, formatFailedLaunchMessage, formatStartingWorkSessionMessage } from './work-session-formatting.lib.mjs';
|
|
19
19
|
import { t } from './i18n.lib.mjs';
|
|
20
20
|
import { lt } from './limits-i18n.lib.mjs';
|
|
21
21
|
export const QueueItemStatus = {
|
|
@@ -1070,9 +1070,24 @@ export class SolveQueue {
|
|
|
1070
1070
|
// This was a bug where the final message update never happened because messageInfo was null
|
|
1071
1071
|
// See: https://github.com/link-assistant/hive-mind/issues/1062
|
|
1072
1072
|
const savedMessageInfo = item.messageInfo;
|
|
1073
|
-
//
|
|
1074
|
-
|
|
1075
|
-
|
|
1073
|
+
// Issue #2154: a launch that never produced a container was still marked
|
|
1074
|
+
// STARTED, counted in `totalCompleted`, pushed onto `completed` and
|
|
1075
|
+
// logged as `Finished: […] (started)`. Three Formal AI tasks that never
|
|
1076
|
+
// ran therefore appear in the bot log as successful starts — a false
|
|
1077
|
+
// positive that hid the incident and contradicted `$ --list`, which had
|
|
1078
|
+
// no such sessions. A refused launch is a failure of the queue item.
|
|
1079
|
+
const launchFailed = Boolean(result) && result.success === false;
|
|
1080
|
+
if (launchFailed) {
|
|
1081
|
+
item.setFailed(result.error || result.output || 'the launch was refused before the container started');
|
|
1082
|
+
item.sessionName = sessionName;
|
|
1083
|
+
item.messageInfo = null; // terminal status — stop tracking the message
|
|
1084
|
+
this.stats.totalFailed++;
|
|
1085
|
+
console.error(`[solve_queue] Item ${item.id} was not launched (session ${sessionName}): ${item.error}`);
|
|
1086
|
+
} else {
|
|
1087
|
+
// Update to Started status (terminal - forgets message tracking)
|
|
1088
|
+
item.setStarted(sessionName);
|
|
1089
|
+
this.stats.totalCompleted++;
|
|
1090
|
+
}
|
|
1076
1091
|
// Final message update using saved messageInfo
|
|
1077
1092
|
if (item.ctx && result && savedMessageInfo) {
|
|
1078
1093
|
const { chatId, messageId } = savedMessageInfo;
|
|
@@ -1089,7 +1104,17 @@ export class SolveQueue {
|
|
|
1089
1104
|
});
|
|
1090
1105
|
await item.ctx.telegram.editMessageText(chatId, messageId, undefined, response, { parse_mode: 'Markdown' });
|
|
1091
1106
|
} else {
|
|
1092
|
-
|
|
1107
|
+
// Issue #2154: a queued /solve that fails to launch reports the
|
|
1108
|
+
// same way as a direct one — with its session UUID and a note
|
|
1109
|
+
// that nothing was started, instead of a bare error dump.
|
|
1110
|
+
const response = formatFailedLaunchMessage({
|
|
1111
|
+
commandName: 'solve',
|
|
1112
|
+
sessionName: sessionName === 'unknown' ? null : sessionName,
|
|
1113
|
+
isolationBackend: result.isolationBackend || null,
|
|
1114
|
+
infoBlock: item.infoBlock,
|
|
1115
|
+
error: result.error || result.output,
|
|
1116
|
+
locale: item.locale,
|
|
1117
|
+
});
|
|
1093
1118
|
await item.ctx.telegram.editMessageText(chatId, messageId, undefined, response, { parse_mode: 'Markdown' });
|
|
1094
1119
|
}
|
|
1095
1120
|
} catch (error) {
|
|
@@ -19,6 +19,7 @@ import { ensureUseM } from './use-m-bootstrap.lib.mjs';
|
|
|
19
19
|
// lib.mjs, so it must not depend on this asynchronous Secretlint layer.
|
|
20
20
|
import { log, isENOSPC } from './lib.mjs';
|
|
21
21
|
import { CREDENTIAL_SANITIZATION_ERROR_CODE, CREDENTIAL_SANITIZATION_FAILURE_MESSAGE, createCredentialStreamSanitizer, findCredentialResiduals, maskToken, sanitizeCredentialText } from './credential-sanitization-core.lib.mjs';
|
|
22
|
+
import { findDecodableRuns, findEncodedKnownTokenRuns, sanitizeEncodedCredentials } from './encoded-credential-detection.lib.mjs'; // issue #2156: credentials that only appear re-encoded
|
|
22
23
|
import { reportError } from './sentry.lib.mjs';
|
|
23
24
|
|
|
24
25
|
export { createCredentialStreamSanitizer };
|
|
@@ -518,6 +519,130 @@ const sanitizeCredentialTextPreservingExclusions = (input, excludedSet) => {
|
|
|
518
519
|
return output;
|
|
519
520
|
};
|
|
520
521
|
|
|
522
|
+
// ---------------------------------------------------------------------------
|
|
523
|
+
// Issue #2156 — known-local tokens that only appear in an encoded form
|
|
524
|
+
// ---------------------------------------------------------------------------
|
|
525
|
+
// The leak in this issue was a `gho_` token that the GHCR token endpoint echoed
|
|
526
|
+
// back base64-encoded inside a JSON body. Every masking layer we had compared
|
|
527
|
+
// bytes literally, so the encoded copy walked straight through. These helpers
|
|
528
|
+
// mask the *encoded* occurrences of tokens we already hold locally.
|
|
529
|
+
// ---------------------------------------------------------------------------
|
|
530
|
+
|
|
531
|
+
/** Encoded-scan recursion limit: base64-of-base64-of-base64 and no deeper. */
|
|
532
|
+
const MAX_ENCODED_KNOWN_TOKEN_DEPTH = 2;
|
|
533
|
+
|
|
534
|
+
/**
|
|
535
|
+
* Replace every verbatim occurrence of the supplied token values.
|
|
536
|
+
*
|
|
537
|
+
* @param {string} text
|
|
538
|
+
* @param {Array<string>} values already filtered and de-duplicated
|
|
539
|
+
* @returns {string}
|
|
540
|
+
*/
|
|
541
|
+
const maskKnownTokenValues = (text, values) => {
|
|
542
|
+
let output = text;
|
|
543
|
+
for (const value of values) {
|
|
544
|
+
if (output.includes(value)) output = output.split(value).join(maskToken(value));
|
|
545
|
+
}
|
|
546
|
+
return output;
|
|
547
|
+
};
|
|
548
|
+
|
|
549
|
+
/**
|
|
550
|
+
* Narrow a raw token list to the values worth searching for.
|
|
551
|
+
*
|
|
552
|
+
* @param {Array<string|{value: string}>} tokens
|
|
553
|
+
* @param {Set<string>} [excludedSet] issue #1745 user-content carve-out
|
|
554
|
+
* @returns {Array<string>}
|
|
555
|
+
*/
|
|
556
|
+
const usableTokenValues = (tokens, excludedSet) => [...new Set((tokens || []).map(t => (typeof t === 'string' ? t : t?.value)).filter(value => typeof value === 'string' && value.length >= 12))].filter(value => !excludedSet?.has(value));
|
|
557
|
+
|
|
558
|
+
/**
|
|
559
|
+
* Mask encoded occurrences of known-local tokens.
|
|
560
|
+
*
|
|
561
|
+
* Decoded payloads are rebuilt rather than dropped: a base64 blob that merely
|
|
562
|
+
* *contains* the token keeps its other fields and stays parseable, and the
|
|
563
|
+
* masked token retains its first/last characters for debugging — the same
|
|
564
|
+
* contract plaintext masking has always offered.
|
|
565
|
+
*
|
|
566
|
+
* @param {string} text
|
|
567
|
+
* @param {Array<string>} values from {@link usableTokenValues}
|
|
568
|
+
* @param {number} [depth] internal recursion counter
|
|
569
|
+
* @returns {string}
|
|
570
|
+
*/
|
|
571
|
+
const maskEncodedKnownTokens = (text, values, depth = 0) => {
|
|
572
|
+
if (values.length === 0) return text;
|
|
573
|
+
return sanitizeEncodedCredentials(text, {
|
|
574
|
+
knownTokens: values,
|
|
575
|
+
sanitizePlaintext: decoded => {
|
|
576
|
+
const masked = maskKnownTokenValues(decoded, values);
|
|
577
|
+
// Peel nested encodings so base64-of-base64 is covered too.
|
|
578
|
+
return depth >= MAX_ENCODED_KNOWN_TOKEN_DEPTH ? masked : maskEncodedKnownTokens(masked, values, depth + 1);
|
|
579
|
+
},
|
|
580
|
+
});
|
|
581
|
+
};
|
|
582
|
+
|
|
583
|
+
/**
|
|
584
|
+
* Mask encoded runs whose *decoded* payload Secretlint recognises.
|
|
585
|
+
*
|
|
586
|
+
* This is the redundancy the issue asks for, aimed at where it actually helps.
|
|
587
|
+
* Secretlint is blind to encoding: its GitHub rule flags a bare `gho_…` but
|
|
588
|
+
* reports nothing for the same token base64-encoded, and neither does any other
|
|
589
|
+
* pattern scanner, because a pattern scanner matches the bytes it is given.
|
|
590
|
+
* Adding a third scanner alongside the first two would therefore have changed
|
|
591
|
+
* nothing about this incident. Decoding first and *then* asking both detectors
|
|
592
|
+
* is what closes the gap, so the external rule set is applied to the decoded
|
|
593
|
+
* payload exactly as the maintained core already is.
|
|
594
|
+
*
|
|
595
|
+
* The two detectors stay independent: this runs whether or not the core found
|
|
596
|
+
* anything, so a credential format Secretlint knows and we do not is still
|
|
597
|
+
* caught once it is decoded.
|
|
598
|
+
*
|
|
599
|
+
* @param {string} text
|
|
600
|
+
* @param {Set<string>} [excludedSet] issue #1745 user-content carve-out
|
|
601
|
+
* @returns {Promise<{text: string, masked: number, ruleIds: Array<string>}>}
|
|
602
|
+
*/
|
|
603
|
+
const maskEncodedSecretsWithSecretlint = async (text, excludedSet) => {
|
|
604
|
+
const runs = findDecodableRuns(text);
|
|
605
|
+
if (runs.length === 0) return { text, masked: 0, ruleIds: [] };
|
|
606
|
+
|
|
607
|
+
// Each payload is scanned on its own rather than as one joined document: a
|
|
608
|
+
// rule that matched across a join boundary would blame a run that is
|
|
609
|
+
// innocent, and masking an innocent run destroys log content.
|
|
610
|
+
const verdicts = await Promise.all(runs.map(run => detectSecretsWithSecretlint(run.decoded)));
|
|
611
|
+
|
|
612
|
+
// Keyed by decoded content, because that is what the sync layer hands back
|
|
613
|
+
// when it re-walks the same runs below. Two runs that decode identically are
|
|
614
|
+
// masked identically, which is what we want.
|
|
615
|
+
const maskedPayloads = new Map();
|
|
616
|
+
const ruleIds = new Set();
|
|
617
|
+
for (const [index, findings] of verdicts.entries()) {
|
|
618
|
+
const usable = findings.filter(finding => !excludedSet?.has(finding.token));
|
|
619
|
+
if (usable.length === 0) continue;
|
|
620
|
+
const { decoded } = runs[index];
|
|
621
|
+
|
|
622
|
+
// Mask inside the decoded payload so the surrounding structure survives.
|
|
623
|
+
// Ranges are spliced from the end so earlier offsets stay valid.
|
|
624
|
+
let payload = decoded;
|
|
625
|
+
for (const finding of [...usable].sort((a, b) => b.start - a.start)) {
|
|
626
|
+
if (payload.substring(finding.start, finding.end) !== finding.token) continue;
|
|
627
|
+
payload = payload.substring(0, finding.start) + maskToken(finding.token) + payload.substring(finding.end);
|
|
628
|
+
ruleIds.add(finding.ruleId);
|
|
629
|
+
}
|
|
630
|
+
if (payload === decoded) continue;
|
|
631
|
+
maskedPayloads.set(decoded, payload);
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
if (maskedPayloads.size === 0) return { text, masked: 0, ruleIds: [] };
|
|
635
|
+
|
|
636
|
+
// Re-encoding, round-trip verification and overlap merging are the sync
|
|
637
|
+
// layer's job. Driving it with a lookup of payloads we have already masked
|
|
638
|
+
// means the two paths cannot disagree about what a masked run looks like.
|
|
639
|
+
const output = sanitizeEncodedCredentials(text, {
|
|
640
|
+
sanitizePlaintext: decoded => maskedPayloads.get(decoded) ?? decoded,
|
|
641
|
+
});
|
|
642
|
+
|
|
643
|
+
return { text: output, masked: maskedPayloads.size, ruleIds: [...ruleIds] };
|
|
644
|
+
};
|
|
645
|
+
|
|
521
646
|
/**
|
|
522
647
|
* Sanitize arbitrary outbound output by masking sensitive tokens while avoiding false positives
|
|
523
648
|
* Uses DUAL APPROACH: Both secretlint AND custom patterns run independently
|
|
@@ -543,6 +668,8 @@ export const sanitizeOutput = async (output, options = {}) => {
|
|
|
543
668
|
const stats = {
|
|
544
669
|
knownTokens: 0,
|
|
545
670
|
secretlintDetections: 0,
|
|
671
|
+
encodedSecretlintDetections: 0,
|
|
672
|
+
encodedSecretlintRuleIds: [],
|
|
546
673
|
customDetections: 0,
|
|
547
674
|
secretlintOnlyWarnings: [],
|
|
548
675
|
customOnlyDetections: [],
|
|
@@ -571,6 +698,17 @@ export const sanitizeOutput = async (output, options = {}) => {
|
|
|
571
698
|
}
|
|
572
699
|
}
|
|
573
700
|
}
|
|
701
|
+
|
|
702
|
+
// Issue #2156: the same tokens, base64/hex/percent-encoded. Byte-for-byte
|
|
703
|
+
// comparison above cannot see those copies.
|
|
704
|
+
const encodableTokens = usableTokenValues(allKnownTokens, excludedSet);
|
|
705
|
+
const beforeEncoded = sanitized;
|
|
706
|
+
sanitized = maskEncodedKnownTokens(sanitized, encodableTokens);
|
|
707
|
+
if (sanitized !== beforeEncoded) {
|
|
708
|
+
stats.knownTokens++;
|
|
709
|
+
sanitizationStats.knownTokenMasks++;
|
|
710
|
+
sanitizationStats.totalMasked++;
|
|
711
|
+
}
|
|
574
712
|
}
|
|
575
713
|
|
|
576
714
|
if (skipOutputSanitization) {
|
|
@@ -663,6 +801,21 @@ export const sanitizeOutput = async (output, options = {}) => {
|
|
|
663
801
|
}
|
|
664
802
|
}
|
|
665
803
|
|
|
804
|
+
// Step 3b (issue #2156): everything above compares against the *surface*
|
|
805
|
+
// text, so a credential that only ever appears encoded is invisible to it —
|
|
806
|
+
// that is exactly how the leaked token survived. The maintained core
|
|
807
|
+
// already reads decoded payloads; run the external rule set over them too,
|
|
808
|
+
// so the two layers cover the same ground and either one can be the catch.
|
|
809
|
+
const beforeEncodedScan = sanitized;
|
|
810
|
+
const encodedScan = await maskEncodedSecretsWithSecretlint(sanitized, excludedSet);
|
|
811
|
+
if (encodedScan.text !== beforeEncodedScan) {
|
|
812
|
+
sanitized = encodedScan.text;
|
|
813
|
+
stats.encodedSecretlintDetections += encodedScan.masked;
|
|
814
|
+
stats.encodedSecretlintRuleIds = encodedScan.ruleIds;
|
|
815
|
+
sanitizationStats.patternMasks += encodedScan.masked;
|
|
816
|
+
sanitizationStats.totalMasked += encodedScan.masked;
|
|
817
|
+
}
|
|
818
|
+
|
|
666
819
|
// Step 4: Handle 40-char hex tokens specially - only mask if NOT in safe context
|
|
667
820
|
// These could be GitHub tokens OR git commit hashes/gist IDs
|
|
668
821
|
const hexPattern = /(?:^|[\s:=])([a-f0-9]{40})(?=[\s\n]|$)/gm;
|
|
@@ -701,11 +854,14 @@ export const sanitizeOutput = async (output, options = {}) => {
|
|
|
701
854
|
}
|
|
702
855
|
|
|
703
856
|
// Summary logging
|
|
704
|
-
const totalMasked = allSecrets.size + hexReplacements.length + stats.knownTokens;
|
|
857
|
+
const totalMasked = allSecrets.size + hexReplacements.length + stats.knownTokens + stats.encodedSecretlintDetections;
|
|
705
858
|
if (global.verboseMode && totalMasked > 0) {
|
|
706
859
|
await log(` 🔒 Sanitized ${totalMasked} secrets using dual approach:`, { verbose: true });
|
|
707
860
|
await log(` • Known tokens: ${stats.knownTokens}`, { verbose: true });
|
|
708
861
|
await log(` • Secretlint: ${stats.secretlintDetections} detections`, { verbose: true });
|
|
862
|
+
if (stats.encodedSecretlintDetections > 0) {
|
|
863
|
+
await log(` • Secretlint (encoded payloads): ${stats.encodedSecretlintDetections} run(s) [${stats.encodedSecretlintRuleIds.join(', ')}]`, { verbose: true });
|
|
864
|
+
}
|
|
709
865
|
await log(` • Custom patterns: ${stats.customDetections} detections`, { verbose: true });
|
|
710
866
|
await log(` • Hex tokens: ${hexReplacements.length}`, { verbose: true });
|
|
711
867
|
if (stats.secretlintOnlyWarnings.length > 0) {
|
|
@@ -929,16 +1085,29 @@ export const getAllKnownLocalTokens = async () => {
|
|
|
929
1085
|
* @param {Array<{value: string, name?: string, source?: string}>} [tokens]
|
|
930
1086
|
* Pre-fetched token list (if you already called getAllKnownLocalTokens).
|
|
931
1087
|
* Pass an explicit list to avoid re-running `gh auth status` per check.
|
|
932
|
-
*
|
|
933
|
-
*
|
|
1088
|
+
* Issue #2156: a token that appears only base64/hex/percent-encoded is a leak
|
|
1089
|
+
* just the same — GitHub's own secret scanning decodes before matching, which
|
|
1090
|
+
* is exactly how the revocation in that issue was triggered. Encoded hits are
|
|
1091
|
+
* reported with the encoding that matched so operators can tell the two cases
|
|
1092
|
+
* apart in the fail-closed publication error path.
|
|
1093
|
+
*
|
|
1094
|
+
* @returns {Promise<Array<{name: string, source: string, encoding: string}>>}
|
|
1095
|
+
* list of token identifiers that were found in the text (NOT the values
|
|
1096
|
+
* themselves).
|
|
934
1097
|
*/
|
|
935
1098
|
export const containsKnownToken = async (text, tokens) => {
|
|
936
1099
|
if (typeof text !== 'string' || text.length === 0) return [];
|
|
937
1100
|
const list = tokens || (await getAllKnownLocalTokens());
|
|
938
1101
|
const hits = [];
|
|
939
1102
|
for (const t of list) {
|
|
940
|
-
if (t.value
|
|
941
|
-
|
|
1103
|
+
if (!t.value) continue;
|
|
1104
|
+
if (text.includes(t.value)) {
|
|
1105
|
+
hits.push({ name: t.name, source: t.source, encoding: 'plaintext' });
|
|
1106
|
+
continue;
|
|
1107
|
+
}
|
|
1108
|
+
const encodedRuns = findEncodedKnownTokenRuns(text, [t.value]);
|
|
1109
|
+
if (encodedRuns.length > 0) {
|
|
1110
|
+
hits.push({ name: t.name, source: t.source, encoding: encodedRuns[0].encoding });
|
|
942
1111
|
}
|
|
943
1112
|
}
|
|
944
1113
|
return hits;
|
|
@@ -979,6 +1148,14 @@ export const sanitizeCommentBody = async (body, options = {}) => {
|
|
|
979
1148
|
sanitizationStats.totalMasked++;
|
|
980
1149
|
}
|
|
981
1150
|
}
|
|
1151
|
+
|
|
1152
|
+
// Issue #2156: the same tokens, re-encoded (base64/hex/percent/escapes).
|
|
1153
|
+
const beforeEncoded = sanitized;
|
|
1154
|
+
sanitized = maskEncodedKnownTokens(sanitized, usableTokenValues(knownTokens, excludedSet));
|
|
1155
|
+
if (sanitized !== beforeEncoded) {
|
|
1156
|
+
sanitizationStats.knownTokenMasks++;
|
|
1157
|
+
sanitizationStats.totalMasked++;
|
|
1158
|
+
}
|
|
982
1159
|
}
|
|
983
1160
|
|
|
984
1161
|
// Pass 2: regex + secretlint sweep for anything else.
|
|
@@ -80,12 +80,58 @@ export function formatStartingWorkSessionMessage({ sessionName = null, isolation
|
|
|
80
80
|
return `${header}\n\n📊 ${sessionLabel}: \`${sessionName}\`${isolationInfo}${details}`;
|
|
81
81
|
}
|
|
82
82
|
|
|
83
|
-
export function formatExecutingWorkSessionMessage({ sessionName = 'unknown', isolationBackend = null, infoBlock = '', locale = null } = {}) {
|
|
83
|
+
export function formatExecutingWorkSessionMessage({ sessionName = 'unknown', executionUuid = null, isolationBackend = null, infoBlock = '', locale = null } = {}) {
|
|
84
84
|
const sessionLabel = text(locale, 'telegram.session_label', 'Session');
|
|
85
85
|
const isolationLabel = text(locale, 'telegram.isolation_label', 'Isolation');
|
|
86
86
|
const isolationInfo = isolationBackend ? `\n🔒 ${isolationLabel}: \`${isolationBackend}\`` : '';
|
|
87
87
|
const details = infoBlock ? `\n\n${infoBlock}` : '';
|
|
88
|
-
|
|
88
|
+
// Issue #2154: `$ --list` identifies an execution by start-command's own UUID,
|
|
89
|
+
// which is *not* the session name shown above it. Printing only one of the two
|
|
90
|
+
// left the operator unable to match a running task to the session list. The
|
|
91
|
+
// line is omitted entirely when start-command reported no UUID, so a session
|
|
92
|
+
// never carries an empty label.
|
|
93
|
+
const executionLabel = text(locale, 'telegram.execution_label', 'Execution');
|
|
94
|
+
const executionInfo = executionUuid ? `\n🆔 ${executionLabel}: \`${executionUuid}\`` : '';
|
|
95
|
+
return `${text(locale, 'telegram.work_session_executing', '⏳ Executing...')}\n\n📊 ${sessionLabel}: \`${sessionName}\`${executionInfo}${isolationInfo}${details}`;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Render the reply for a work session that never started (issue #2154).
|
|
100
|
+
*
|
|
101
|
+
* The previous reply was the raw runner error in a code fence and nothing else.
|
|
102
|
+
* Two things were missing, and both were reported as separate symptoms of the
|
|
103
|
+
* same incident:
|
|
104
|
+
*
|
|
105
|
+
* - **The session UUID.** It was generated before the launch and shown in the
|
|
106
|
+
* "🔄 Starting..." message, but the failure reply *overwrote* that message,
|
|
107
|
+
* so the only identifier the task ever had was destroyed by its own error
|
|
108
|
+
* report. Nothing then connected the Telegram thread to the bot log lines,
|
|
109
|
+
* to the session store, or to a `--log <uuid>` lookup.
|
|
110
|
+
* - **Why the task is missing from `--list`.** A failed launch produces no
|
|
111
|
+
* container, so the session is untracked and never appears in the listing.
|
|
112
|
+
* Without saying so, the reply reads as if the task were running somewhere.
|
|
113
|
+
*
|
|
114
|
+
* @param {Object} params
|
|
115
|
+
* @param {string} [params.commandName] - Command the user invoked (`solve`, `hive`, …)
|
|
116
|
+
* @param {string|null} [params.sessionName] - Session UUID, when one was generated
|
|
117
|
+
* @param {string|null} [params.isolationBackend]
|
|
118
|
+
* @param {string} [params.infoBlock]
|
|
119
|
+
* @param {string} [params.error] - Runner error text
|
|
120
|
+
* @param {string|null} [params.locale]
|
|
121
|
+
* @returns {string} Markdown reply
|
|
122
|
+
*
|
|
123
|
+
* @see https://github.com/link-assistant/hive-mind/issues/2154
|
|
124
|
+
*/
|
|
125
|
+
export function formatFailedLaunchMessage({ commandName = 'command', sessionName = null, isolationBackend = null, infoBlock = '', error = '', locale = null } = {}) {
|
|
126
|
+
const header = text(locale, 'telegram.error_executing_command', `❌ Error executing ${commandName} command`, { commandName });
|
|
127
|
+
const sessionLabel = text(locale, 'telegram.session_label', 'Session');
|
|
128
|
+
const isolationLabel = text(locale, 'telegram.isolation_label', 'Isolation');
|
|
129
|
+
const sessionLine = sessionName ? `\n📊 ${sessionLabel}: \`${sessionName}\`` : '';
|
|
130
|
+
const isolationLine = isolationBackend ? `\n🔒 ${isolationLabel}: \`${isolationBackend}\`` : '';
|
|
131
|
+
const body = String(error ?? '').trim() || 'unknown error';
|
|
132
|
+
const notLaunched = sessionName ? `\n\n${text(locale, 'telegram.work_session_not_launched', 'The work session was not launched, so it has no log and is not listed by `--list`.')}` : '';
|
|
133
|
+
const details = infoBlock ? `\n\n${infoBlock}` : '';
|
|
134
|
+
return `${header}:${sessionLine}${isolationLine}\n\n\`\`\`\n${body}\n\`\`\`${notLaunched}${details}`;
|
|
89
135
|
}
|
|
90
136
|
|
|
91
137
|
/**
|
|
@@ -176,6 +222,13 @@ export function formatSessionCompletionMessage({ sessionName, sessionInfo, statu
|
|
|
176
222
|
const sessionLabel = text(messageLocale, 'telegram.session_label', 'Session');
|
|
177
223
|
const isolationLabel = text(messageLocale, 'telegram.isolation_label', 'Isolation');
|
|
178
224
|
const isolationInfo = sessionInfo?.isolationBackend ? `\n🔒 ${isolationLabel}: \`${sessionInfo.isolationBackend}\`` : '';
|
|
225
|
+
// Issue #2154: the completion reply is the last word the Telegram thread has
|
|
226
|
+
// on a task, and the handle an operator uses afterwards to fetch its log. Keep
|
|
227
|
+
// start-command's execution UUID (the one `$ --list` prints) next to the
|
|
228
|
+
// session UUID, so the finished task can still be found in the session list.
|
|
229
|
+
const executionLabel = text(messageLocale, 'telegram.execution_label', 'Execution');
|
|
230
|
+
const executionUuid = sessionInfo?.executionUuid || statusResult?.uuid || null;
|
|
231
|
+
const executionInfo = executionUuid ? `\n🆔 ${executionLabel}: \`${executionUuid}\`` : '';
|
|
179
232
|
const startTime = parseDateValue(statusResult?.startTime) || parseDateValue(sessionInfo?.startTime) || observedEndTime;
|
|
180
233
|
const endTime = parseDateValue(statusResult?.endTime) || observedEndTime;
|
|
181
234
|
const durationSeconds = Math.max(0, (endTime.getTime() - startTime.getTime()) / 1000);
|
|
@@ -188,7 +241,7 @@ export function formatSessionCompletionMessage({ sessionName, sessionInfo, statu
|
|
|
188
241
|
const statusEmoji = statusEmojiOverride || (failed ? '❌' : '✅');
|
|
189
242
|
let message = `${statusEmoji} *${statusText}*\n\n`;
|
|
190
243
|
message += `⏱️ ${durationLabel}: ${formatSessionDurationSeconds(durationSeconds)}\n`;
|
|
191
|
-
message += `📊 ${sessionLabel}: \`${sessionName || 'unknown'}\`${isolationInfo}${details}`;
|
|
244
|
+
message += `📊 ${sessionLabel}: \`${sessionName || 'unknown'}\`${executionInfo}${isolationInfo}${details}`;
|
|
192
245
|
|
|
193
246
|
// Issue #594: --show-limits virtual option appends snapshot/delta sections
|
|
194
247
|
// (Markdown code blocks) below the standard completion details.
|