@makerbi/remodex 2.3.1 → 2.4.0

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.
@@ -4,14 +4,29 @@
4
4
  // Exports: createPushNotificationTracker
5
5
  // Depends on: ./push-notification-completion-dedupe
6
6
 
7
+ const fs = require("fs");
8
+ const os = require("os");
9
+ const path = require("path");
10
+
7
11
  const {
8
12
  createPushNotificationCompletionDedupe,
9
13
  } = require("./push-notification-completion-dedupe");
10
14
 
15
+ const DEFAULT_GOAL_PUSH_STATE_PATH = path.join(os.homedir(), ".remodex", "goal-push-state.json");
16
+
11
17
  const DEFAULT_PREVIEW_MAX_CHARS = 160;
12
18
  const MAX_THREAD_TITLE_ENTRIES = 200;
13
19
  const MAX_TURN_STATE_ENTRIES = 500;
14
20
  const MAX_THREAD_ID_BY_TURN_ENTRIES = 500;
21
+ const MAX_GOAL_STATUS_ENTRIES = 500;
22
+
23
+ // Goal states worth waking the phone for: terminal or needs-user-attention.
24
+ const GOAL_PUSH_BODIES = new Map([
25
+ ["complete", "Goal complete"],
26
+ ["blocked", "Goal blocked — Codex needs your input"],
27
+ ["usageLimited", "Goal stopped — usage limit reached"],
28
+ ["budgetLimited", "Goal stopped — token budget reached"],
29
+ ]);
15
30
 
16
31
  function createPushNotificationTracker({
17
32
  sessionId,
@@ -19,10 +34,14 @@ function createPushNotificationTracker({
19
34
  previewMaxChars = DEFAULT_PREVIEW_MAX_CHARS,
20
35
  logPrefix = "[remodex]",
21
36
  now = () => Date.now(),
37
+ goalPushStatePath = DEFAULT_GOAL_PUSH_STATE_PATH,
22
38
  } = {}) {
23
39
  const threadTitleById = new Map();
24
40
  const threadIdByTurnId = new Map();
25
41
  const turnStateByKey = new Map();
42
+ // Persisted across bridge restarts so goal transitions that happened while the
43
+ // bridge was down still notify on the first post-restart snapshot.
44
+ const goalStatusByThreadId = loadGoalPushState(goalPushStatePath, logPrefix);
26
45
  const completionDedupe = createPushNotificationCompletionDedupe({ now });
27
46
 
28
47
  // ─── ENTRY POINT ─────────────────────────────────────────────
@@ -33,6 +52,18 @@ function createPushNotificationTracker({
33
52
  return;
34
53
  }
35
54
 
55
+ if (message.method === "thread/goal/updated") {
56
+ void handleGoalUpdated(message);
57
+ return;
58
+ }
59
+
60
+ if (message.method === "thread/goal/cleared") {
61
+ if (message.threadId && goalStatusByThreadId.delete(message.threadId)) {
62
+ saveGoalPushState(goalPushStatePath, goalStatusByThreadId, logPrefix);
63
+ }
64
+ return;
65
+ }
66
+
36
67
  rememberMessageContext(message);
37
68
  clearFallbackSuppressionForNewRun(message);
38
69
 
@@ -73,6 +104,59 @@ function createPushNotificationTracker({
73
104
  }
74
105
  }
75
106
 
107
+ // Pushes goal lifecycle transitions into terminal/attention states so hours-long
108
+ // background goals still reach the user. Resume snapshots (first observation of a
109
+ // status) never notify; only live status changes do.
110
+ async function handleGoalUpdated({ threadId, params }) {
111
+ const goal = objectValue(params?.goal);
112
+ const status = readString(goal?.status);
113
+ const resolvedThreadId = threadId || readString(goal?.threadId);
114
+ if (!resolvedThreadId || !status) {
115
+ return;
116
+ }
117
+
118
+ const previousSnapshot = normalizeGoalPushSnapshot(goalStatusByThreadId.get(resolvedThreadId));
119
+ const nextSnapshot = {
120
+ status,
121
+ updatedAt: goal?.updatedAt ?? goal?.updated_at ?? null,
122
+ };
123
+ if (!goalStatusByThreadId.has(resolvedThreadId) && goalStatusByThreadId.size >= MAX_GOAL_STATUS_ENTRIES) {
124
+ const oldest = goalStatusByThreadId.keys().next().value;
125
+ goalStatusByThreadId.delete(oldest);
126
+ }
127
+ const isFirstObservation = previousSnapshot == null;
128
+ const isDuplicate = previousSnapshot?.status === nextSnapshot.status
129
+ && previousSnapshot?.updatedAt === nextSnapshot.updatedAt;
130
+ const body = GOAL_PUSH_BODIES.get(status);
131
+ if (isFirstObservation || isDuplicate || !body || !pushServiceClient?.hasConfiguredBaseUrl) {
132
+ if (!isDuplicate) {
133
+ goalStatusByThreadId.set(resolvedThreadId, nextSnapshot);
134
+ saveGoalPushState(goalPushStatePath, goalStatusByThreadId, logPrefix);
135
+ }
136
+ return;
137
+ }
138
+
139
+ const title = normalizePreviewText(threadTitleById.get(resolvedThreadId)) || "New Thread";
140
+ // The goal objective intentionally stays out of push payloads and logs.
141
+ try {
142
+ await pushServiceClient.notifyCompletion({
143
+ threadId: resolvedThreadId,
144
+ turnId: null,
145
+ result: status === "complete" ? "completed" : "failed",
146
+ title,
147
+ body,
148
+ // updatedAt keeps repeated legitimate transitions (blocked -> active -> blocked) notifiable.
149
+ dedupeKey: [sessionId || "", resolvedThreadId, "goal", status, goal?.updatedAt ?? ""].join("|"),
150
+ });
151
+ // Commit the dedupe cursor only after delivery succeeds so a repeated
152
+ // app-server snapshot can retry a transient push outage.
153
+ goalStatusByThreadId.set(resolvedThreadId, nextSnapshot);
154
+ saveGoalPushState(goalPushStatePath, goalStatusByThreadId, logPrefix);
155
+ } catch (error) {
156
+ console.error(`${logPrefix} goal push notify failed: ${error.message}`);
157
+ }
158
+ }
159
+
76
160
  // Remembers thread/turn linkage before the terminal event arrives on a different payload shape.
77
161
  function rememberMessageContext({ threadId, turnId, params, eventObject }) {
78
162
  if (threadId && turnId) {
@@ -273,6 +357,56 @@ function createPushNotificationTracker({
273
357
  };
274
358
  }
275
359
 
360
+ // Best-effort disk persistence for goal statuses; failures must never break the bridge.
361
+ function loadGoalPushState(filePath, logPrefix) {
362
+ if (!filePath) {
363
+ return new Map();
364
+ }
365
+
366
+ try {
367
+ const parsed = JSON.parse(fs.readFileSync(filePath, "utf8"));
368
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
369
+ return new Map();
370
+ }
371
+ const entries = Object.entries(parsed)
372
+ .map(([threadId, snapshot]) => [threadId, normalizeGoalPushSnapshot(snapshot)])
373
+ .filter(([threadId, snapshot]) => typeof threadId === "string" && snapshot != null)
374
+ .slice(-MAX_GOAL_STATUS_ENTRIES);
375
+ return new Map(entries);
376
+ } catch (error) {
377
+ if (error.code !== "ENOENT") {
378
+ console.error(`${logPrefix} failed to load goal push state: ${error.message}`);
379
+ }
380
+ return new Map();
381
+ }
382
+ }
383
+
384
+ function normalizeGoalPushSnapshot(value) {
385
+ if (typeof value === "string") {
386
+ return { status: value, updatedAt: null };
387
+ }
388
+ if (!value || typeof value !== "object" || typeof value.status !== "string") {
389
+ return null;
390
+ }
391
+ return {
392
+ status: value.status,
393
+ updatedAt: value.updatedAt ?? null,
394
+ };
395
+ }
396
+
397
+ function saveGoalPushState(filePath, goalStatusByThreadId, logPrefix) {
398
+ if (!filePath) {
399
+ return;
400
+ }
401
+
402
+ try {
403
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
404
+ fs.writeFileSync(filePath, JSON.stringify(Object.fromEntries(goalStatusByThreadId)));
405
+ } catch (error) {
406
+ console.error(`${logPrefix} failed to save goal push state: ${error.message}`);
407
+ }
408
+ }
409
+
276
410
  // Normalizes the message envelope once so downstream helpers can share the same parsed view.
277
411
  function parseOutboundMessage(rawMessage, parsedMessage = null) {
278
412
  const parsed = parsedMessage ?? safeParseJSON(rawMessage);
@@ -132,9 +132,17 @@ function createRolloutLiveMirrorController({
132
132
  mirrorsByThreadId.clear();
133
133
  }
134
134
 
135
+ // The real turn id this mirror is actively tailing, or null. Lets the bridge
136
+ // answer the phone's turn-state probe from mirror truth when the bounded
137
+ // canonical page reads a busy run as closed.
138
+ function getActiveTurnId(threadId) {
139
+ return mirrorsByThreadId.get(threadId)?.getActiveTurnId() || null;
140
+ }
141
+
135
142
  return {
136
143
  observeInbound,
137
144
  stopAll,
145
+ getActiveTurnId,
138
146
  };
139
147
  }
140
148
 
@@ -358,9 +366,30 @@ function createThreadRolloutLiveMirror({
358
366
  onStop();
359
367
  }
360
368
 
369
+ // Only a healthy, actively-tailed run with a real id counts: synthetic ids
370
+ // are not actionable app-server turn ids, and suppressed/awaiting states
371
+ // mean the mirror does not actually know what is running. While another live
372
+ // source owns the thread the tail keeps parsing with its emissions muted, so
373
+ // reporting that turn id would resurrect exactly the state the bridge muted.
374
+ function getActiveTurnId() {
375
+ if (
376
+ isStopped
377
+ || wasSuppressed
378
+ || state.isDesktopOrigin === false
379
+ || state.awaitingCoherentBoundary
380
+ || state.suppressLiveActivityUntilGrowth
381
+ || state.activeTurnIdIsSynthetic
382
+ || state.pendingSyntheticTerminalTurnId
383
+ ) {
384
+ return null;
385
+ }
386
+ return state.activeTurnId || null;
387
+ }
388
+
361
389
  return {
362
390
  bump,
363
391
  stop,
392
+ getActiveTurnId,
364
393
  };
365
394
  }
366
395
 
@@ -401,12 +430,30 @@ function bootstrapFromExistingRollout({
401
430
  fsModule,
402
431
  });
403
432
  if (!bootstrapWindow) {
404
- // The active run starts outside the bounded bootstrap window. Do not emit
405
- // a plausible-looking tail: canonical history remains the baseline and
406
- // this mirror will still consume future growth normally.
407
433
  state.awaitingCoherentBoundary = true;
408
434
  return;
409
435
  }
436
+ if (!bootstrapWindow.coherent) {
437
+ // The active run starts outside the bounded bootstrap window. Do not emit
438
+ // a plausible-looking tail: canonical history remains the baseline. But do
439
+ // not go dark either — a long busy run would stop mirroring tool activity
440
+ // until its next turn boundary. Attach to the run in place instead, so
441
+ // growth from here on keeps streaming live.
442
+ const attached = attachToActiveRunFromTruncatedTail({
443
+ contents: bootstrapWindow.alignedContents,
444
+ boundary: bootstrapWindow.boundary,
445
+ state,
446
+ rolloutPath,
447
+ fsModule,
448
+ sendApplicationResponse,
449
+ nowMs,
450
+ staleActiveRunMaxAgeMs,
451
+ });
452
+ if (!attached) {
453
+ state.awaitingCoherentBoundary = true;
454
+ }
455
+ return;
456
+ }
410
457
  const { tailStart, contents: bootstrapContents } = bootstrapWindow;
411
458
  let initialContents = bootstrapContents;
412
459
  if (!initialContents) {
@@ -532,7 +579,8 @@ function bootstrapFromExistingRollout({
532
579
  // Expands backwards only until the newest active task has its opening user
533
580
  // message. Every expansion reads just the newly needed prefix, so a 30MB file
534
581
  // is read at most once rather than once per retry. The hard cap keeps bootstrap
535
- // work/memory bounded; no coherent opener means no replay.
582
+ // work/memory bounded; a capped window without a coherent opener comes back
583
+ // with `coherent: false` and must never be replayed as history.
536
584
  function readCoherentBootstrapWindow({ rolloutPath, fileSize, fsModule }) {
537
585
  const maxBytes = Math.min(fileSize, DEFAULT_BOOTSTRAP_MAX_BYTES);
538
586
  let windowBytes = Math.min(fileSize, DEFAULT_BOOTSTRAP_TAIL_BYTES);
@@ -549,10 +597,16 @@ function readCoherentBootstrapWindow({ rolloutPath, fileSize, fsModule }) {
549
597
  // some legitimate system/continuation turns have no materialized user row.
550
598
  // The opener requirement only protects a truncated tail.
551
599
  if (!boundary.hasActiveRun || boundary.hasOpeningUser || tailStart === 0) {
552
- return { tailStart, contents };
600
+ return { tailStart, contents, coherent: true };
553
601
  }
554
602
  if (windowBytes >= maxBytes || tailStart === 0) {
555
- return null;
603
+ return {
604
+ tailStart,
605
+ contents,
606
+ coherent: false,
607
+ alignedContents,
608
+ boundary,
609
+ };
556
610
  }
557
611
 
558
612
  const nextWindowBytes = Math.min(maxBytes, windowBytes * 2);
@@ -584,12 +638,21 @@ function inspectBootstrapRunBoundary(contents) {
584
638
  // closing terminal is evidence of an unknown active boundary, not permission
585
639
  // to replay a partial conversation.
586
640
  let unboundedActivitySinceTerminal = false;
587
-
588
- for (const rawLine of contents.split("\n")) {
589
- const parsed = safeParseJSON(rawLine.trim());
641
+ // Attach metadata for the incoherent-window case, so the caller never has to
642
+ // re-parse the (up to 64MB) window a second time.
643
+ let newestTaskStartedLineIndex = -1;
644
+ let lastEntryTimestamp = "";
645
+
646
+ const lines = contents.split("\n");
647
+ for (let lineIndex = 0; lineIndex < lines.length; lineIndex += 1) {
648
+ const parsed = safeParseJSON(lines[lineIndex].trim());
590
649
  if (!parsed) {
591
650
  continue;
592
651
  }
652
+ const entryTimestamp = readString(parsed.timestamp);
653
+ if (entryTimestamp) {
654
+ lastEntryTimestamp = entryTimestamp;
655
+ }
593
656
  const taskEventType = parsed?.type === "event_msg"
594
657
  ? readString(parsed?.payload?.type)
595
658
  : "";
@@ -611,6 +674,7 @@ function inspectBootstrapRunBoundary(contents) {
611
674
  hasOpeningUser = pendingUserBeforeStart;
612
675
  hasTurnOutputSinceStart = false;
613
676
  pendingUserBeforeStart = false;
677
+ newestTaskStartedLineIndex = lineIndex;
614
678
  continue;
615
679
  }
616
680
  if (!activeTurnId) {
@@ -653,6 +717,8 @@ function inspectBootstrapRunBoundary(contents) {
653
717
  return {
654
718
  hasActiveRun: Boolean(activeTurnId) || unboundedActivitySinceTerminal,
655
719
  hasOpeningUser,
720
+ newestTaskStartedLineIndex,
721
+ lastEntryTimestamp,
656
722
  };
657
723
  }
658
724
 
@@ -667,6 +733,58 @@ function isBootstrapNeutralRecord(entry, taskEventType = "") {
667
733
  || taskEventType === "context_updated";
668
734
  }
669
735
 
736
+ // Attaches mid-run when the active run's opener is beyond the bounded window:
737
+ // nothing already in the tail is emitted (it stays canonical-history
738
+ // territory), but run state is hydrated so subsequent growth mirrors live.
739
+ // Returns false when the tail proves the visible runs all closed — trailing
740
+ // bytes then belong to an unknown older boundary and stay suppressed.
741
+ function attachToActiveRunFromTruncatedTail({
742
+ contents,
743
+ boundary,
744
+ state,
745
+ rolloutPath,
746
+ fsModule,
747
+ sendApplicationResponse,
748
+ nowMs,
749
+ staleActiveRunMaxAgeMs,
750
+ }) {
751
+ const newestTaskStartedIndex = boundary?.newestTaskStartedLineIndex ?? -1;
752
+ if (newestTaskStartedIndex >= 0) {
753
+ // Hydrate through the shared reducer so parallel-turn and terminal
754
+ // semantics stay authoritative for what is still open at EOF.
755
+ processRolloutLines(contents.split("\n").slice(newestTaskStartedIndex), state, () => {});
756
+ if (!state.activeTurnId) {
757
+ return false;
758
+ }
759
+ } else {
760
+ // Mid-turn tail without its task_started: adopt a synthetic turn. The
761
+ // first non-terminal event carrying the real id promotes it, and a
762
+ // mismatched terminal closes it via the synthetic-terminal path.
763
+ state.activeTurnId = buildSyntheticTurnId(state, { timestamp: boundary?.lastEntryTimestamp || "" });
764
+ state.activeTurnIdIsSynthetic = true;
765
+ state.reasoningItemId = buildSyntheticItemId("thinking", state.threadId, state.activeTurnId);
766
+ }
767
+
768
+ if (isRolloutFileStale(rolloutPath, fsModule, nowMs, staleActiveRunMaxAgeMs)) {
769
+ // Same contract as the stale coherent bootstrap: stay silent until real
770
+ // growth proves the desktop process is alive again.
771
+ state.suppressLiveActivityUntilGrowth = true;
772
+ return true;
773
+ }
774
+
775
+ // A hydrated run that already carries a pending synthetic terminal is
776
+ // closing, not running: announcing it as live would just be followed by the
777
+ // tick's synthetic turn/completed one grace period later.
778
+ if (!state.pendingSyntheticTerminalTurnId) {
779
+ sendApplicationResponse(JSON.stringify(createNotification("turn/activity", {
780
+ threadId: state.threadId,
781
+ turnId: state.activeTurnId,
782
+ id: state.activeTurnId,
783
+ })));
784
+ }
785
+ return true;
786
+ }
787
+
670
788
  // After a bounded bootstrap cannot reach the old opener, consume only new
671
789
  // bytes. A later real user+task_started boundary safely starts a new live run;
672
790
  // everything before it remains canonical-history territory.
@@ -799,6 +917,26 @@ function synthesizeNotificationsFromRolloutEntry(entry, state, { nowMs = Date.no
799
917
  const payload = entry.payload || {};
800
918
  const eventType = readString(payload.type);
801
919
 
920
+ if (eventType === "thread_goal_updated") {
921
+ const goal = payload.goal && typeof payload.goal === "object" ? payload.goal : null;
922
+ const threadId = readString(payload.threadId) || readString(goal?.threadId) || state.threadId;
923
+ if (!goal || !threadId) {
924
+ return [];
925
+ }
926
+ return [createNotification("thread/goal/updated", {
927
+ threadId,
928
+ turnId: readString(payload.turnId) || readString(payload.turn_id) || null,
929
+ goal,
930
+ })];
931
+ }
932
+
933
+ if (eventType === "thread_goal_cleared") {
934
+ const threadId = readString(payload.threadId) || readString(payload.thread_id) || state.threadId;
935
+ return threadId
936
+ ? [createNotification("thread/goal/cleared", { threadId })]
937
+ : [];
938
+ }
939
+
802
940
  if (eventType === "task_started") {
803
941
  notifications.push(...finalizePendingSyntheticTerminal(state));
804
942
  const explicitTurnId = readString(payload.turn_id) || readString(payload.turnId);
@@ -952,7 +1090,7 @@ function synthesizeNotificationsFromRolloutEntry(entry, state, { nowMs = Date.no
952
1090
  return notifications;
953
1091
  }
954
1092
 
955
- if (itemType === "functioncalloutput") {
1093
+ if (itemType === "functioncalloutput" || itemType === "customtoolcalloutput") {
956
1094
  notifications.push(...toolOutputNotifications(state, payload));
957
1095
  return notifications;
958
1096
  }
@@ -1316,6 +1454,16 @@ function customToolStartNotifications(state, payload) {
1316
1454
  return notifications;
1317
1455
  }
1318
1456
 
1457
+ // Custom tool calls settle through custom_tool_call_output. Without tracking
1458
+ // them the activity row never completes, so it lingers between command groups.
1459
+ if (!isCommandToolName(toolName) && !state.applyPatchCalls.has(callId)) {
1460
+ state.commandCalls.set(callId, {
1461
+ toolName,
1462
+ command: toolName,
1463
+ cwd: readString(state.sessionMeta?.cwd) || "",
1464
+ });
1465
+ }
1466
+
1319
1467
  return [
1320
1468
  ...notifications,
1321
1469
  createNotification("codex/event/background_event", {
@@ -1776,8 +1924,19 @@ function genericToolActivityMessage(toolName) {
1776
1924
  }
1777
1925
  }
1778
1926
 
1927
+ // Mirrors the wording of genericToolActivityMessage so the completion line
1928
+ // supersedes the start line instead of stacking a second row beside it.
1779
1929
  function genericToolCompletionMessage(toolName) {
1780
- return `Completed ${readString(toolName)}`;
1930
+ switch (readString(toolName).toLowerCase()) {
1931
+ case "apply_patch":
1932
+ return "Applied patch";
1933
+ case "write_stdin":
1934
+ return "Wrote to terminal";
1935
+ case "read_thread_terminal":
1936
+ return "Read terminal output";
1937
+ default:
1938
+ return `Completed ${readString(toolName)}`;
1939
+ }
1781
1940
  }
1782
1941
 
1783
1942
  function createNotification(method, params = {}) {
@@ -10,6 +10,7 @@ const {
10
10
  isContextualUserText,
11
11
  isUserRoleItem,
12
12
  responseItemMessageText: sharedResponseItemMessageText,
13
+ sanitizeUserRoleItem,
13
14
  visibleUserPromptText,
14
15
  } = require("./desktop-ipc-shared");
15
16
 
@@ -862,7 +863,10 @@ function normalizeResponseItemForHistory(payload, lineNumber, { cwd = "", toolCa
862
863
  item.role = "assistant";
863
864
  }
864
865
 
865
- return item;
866
+ // A single user item can carry injected context next to the real request.
867
+ // Sanitize here so history readers (including the thread/read JSONL merge,
868
+ // which runs after the relay sanitizer) never rebuild the hidden fragments.
869
+ return isUserRoleItem(item) ? sanitizeUserRoleItem(item) : item;
866
870
  }
867
871
 
868
872
  function applyHistoryAssistantSourceAlias(item, turnId, occurrencesByBaseKey = new Map()) {