@link-assistant/hive-mind 2.10.0 → 2.10.1

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 CHANGED
@@ -1,5 +1,11 @@
1
1
  # @link-assistant/hive-mind
2
2
 
3
+ ## 2.10.1
4
+
5
+ ### Patch Changes
6
+
7
+ - a9b0967: Fix killed Telegram task resume instructions by recovering the last tool session from the complete task log, rejecting unrelated shared-directory session logs, and preserving the original slash-command alias through queued and restarted sessions.
8
+
3
9
  ## 2.10.0
4
10
 
5
11
  ### Minor Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@link-assistant/hive-mind",
3
- "version": "2.10.0",
3
+ "version": "2.10.1",
4
4
  "description": "AI-powered issue solver and hive mind for collaborative problem solving",
5
5
  "main": "src/hive.mjs",
6
6
  "type": "module",
@@ -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
@@ -820,13 +820,13 @@ async function handleSolveCommand(ctx) {
820
820
 
821
821
  if (check.canStart && check.startReserved) {
822
822
  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 });
823
+ await executeAndUpdateMessage(ctx, startingMessage, 'solve', argsWithLocale, infoBlock, effectiveSolveIsolation, solveTool, solveUrlContext, { showLimits: solveShowLimits, limitsAtStart: solveLimitsAtStart, locale: solveLocale, commandAlias: solveCommandName });
824
824
  } else {
825
825
  if (!solveQueue.executeCallback) {
826
826
  const _t = (s, i) => trackSession(s, i, VERBOSE);
827
827
  solveQueue.executeCallback = createIsolationAwareQueueCallback(ISOLATION_BACKEND, isolationRunner, _t, createQueueExecuteCallback(executeStartScreen, _t), VERBOSE);
828
828
  }
829
- const queueItem = solveQueue.enqueue({ url: normalizedUrl, args: argsWithLocale, ctx, requester, infoBlock, tool: solveTool, perCommandIsolation: effectiveSolveIsolation, urlContext: solveUrlContext, showLimits: solveShowLimits, limitsAtStart: solveLimitsAtStart, locale: solveLocale });
829
+ 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
830
  const queueMessage = buildSolveQueuedMessage({ locale: solveLocale, tool: solveTool, position: toolQueuedCount + 1, infoBlock, reason: check.reason ? escapeMarkdown(check.reason) : '' }); // tool-specific position (#1551)
831
831
  const queuedMessage = await safeReply(ctx, queueMessage, { reply_to_message_id: ctx.message.message_id });
832
832
  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) {
@@ -90,11 +90,13 @@ export function createIsolationAwareQueueCallback(botIsolationBackend, botIsolat
90
90
  startTime: new Date(),
91
91
  url: item.url,
92
92
  command: item.command || 'solve',
93
+ commandAlias: item.commandAlias || null,
93
94
  isolationBackend: iso.backend,
94
95
  sessionId: sid,
95
96
  containerFilesystemStartBytes: Number.isFinite(r.containerFilesystemStartBytes) ? r.containerFilesystemStartBytes : null,
96
97
  tool,
97
98
  infoBlock: item.infoBlock,
99
+ args: Array.isArray(item.args) ? [...item.args] : undefined,
98
100
  // Issue #1688: propagate URL context + requester through the queue so the
99
101
  // completion notification can append a 'Pull request:' line and skip
100
102
  // notifying the requester twice via /subscribe.
@@ -53,6 +53,7 @@ class SolveQueueItem {
53
53
  this.ctx = options.ctx;
54
54
  this.requester = options.requester;
55
55
  this.infoBlock = options.infoBlock;
56
+ this.commandAlias = options.commandAlias || null; // #2109: retain Telegram spelling for resume guidance
56
57
  this.tool = options.tool || 'claude';
57
58
  // Issue #1983: preserve per-command isolation through queued execution.
58
59
  this.perCommandIsolation = options.perCommandIsolation || null;
@@ -1442,16 +1443,14 @@ export function createQueueExecuteCallback(executeStartScreen, trackSessionFn) {
1442
1443
  startTime: new Date(),
1443
1444
  url: item.url,
1444
1445
  command: 'solve',
1446
+ commandAlias: item.commandAlias || null,
1445
1447
  tool: item.tool || 'claude',
1446
1448
  infoBlock: item.infoBlock,
1447
- // Issue #1688: propagate URL context + requester so the completion
1448
- // notification can append a 'Pull request:' line and skip
1449
- // notifying the requester twice via /subscribe.
1449
+ args: Array.isArray(item.args) ? [...item.args] : undefined,
1450
+ // #1688: propagate URL context and requester to completion.
1450
1451
  urlContext: item.urlContext || null,
1451
1452
  requesterUserId: item.requesterUserId ?? null,
1452
- // Issue #594: --show-limits virtual option carries the start-of-task
1453
- // snapshot from the queueing point through to the completion
1454
- // handler so it can render an end-of-task delta.
1453
+ // #594: carry the start-of-task limits snapshot to completion.
1455
1454
  showLimits: item.showLimits === true,
1456
1455
  limitsAtStart: item.limitsAtStart || null,
1457
1456
  locale: item.locale || null,