@leviyuan/lodestar 0.2.7 → 0.2.8

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@leviyuan/lodestar",
3
- "version": "0.2.7",
3
+ "version": "0.2.8",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
package/src/cardkit.ts CHANGED
@@ -81,7 +81,12 @@ async function call(method: string, path: string, body?: object): Promise<any> {
81
81
  }
82
82
 
83
83
  function isStreamingClosed(e: unknown): boolean {
84
- return typeof e === 'object' && e !== null && (e as any).code === 300309
84
+ if (typeof e !== 'object' || e === null) return false
85
+ const code = (e as any).code
86
+ // 300309 "streaming mode is closed" — TTL already fired before our write.
87
+ // 200850 "card streaming timeout" — TTL fired exactly during our write.
88
+ // Both mean the streaming session is gone and a reopen will unstick the card.
89
+ return code === 300309 || code === 200850
85
90
  }
86
91
 
87
92
  /** Reopen streaming_mode on a card that Feishu auto-closed after its
@@ -98,11 +103,11 @@ async function reopenStreaming(cardId: string): Promise<void> {
98
103
  }
99
104
 
100
105
  /** Run `op` inside the per-card queue. If it fails with code=300309
101
- * (Feishu auto-closed streaming after the 10-minute TTL), reopen
102
- * streaming inline and retry `op` exactly once. Anything else — non-
103
- * 300309 failure, reopen failure, retry failure — is logged and
104
- * swallowed, matching the fire-and-forget contract every cardkit op
105
- * already has at the call sites. */
106
+ * or 200850 (Feishu auto-closed / timed-out streaming after the 10-
107
+ * minute TTL), reopen streaming inline and retry `op` exactly once.
108
+ * Anything else — other failure, reopen failure, retry failure — is
109
+ * logged and swallowed, matching the fire-and-forget contract every
110
+ * cardkit op already has at the call sites. */
106
111
  async function withReopenOnStreamingClosed(
107
112
  cardId: string,
108
113
  label: string,
@@ -118,7 +123,7 @@ async function withReopenOnStreamingClosed(
118
123
  if (onFailure) onFailure()
119
124
  return
120
125
  }
121
- log(`cardkit ${label} ${cardId}: streaming closed (300309) — reopening`)
126
+ log(`cardkit ${label} ${cardId}: streaming closed (code=${(e as any).code}) — reopening`)
122
127
  }
123
128
  try {
124
129
  await reopenStreaming(cardId)
package/src/session.ts CHANGED
@@ -118,18 +118,25 @@ export class Session {
118
118
  private currentTurn: TurnState | null = null
119
119
  /** Count of user messages we've written to Claude's stdin since the last
120
120
  * turn opened on our side. NOT a FIFO of individual messages — the SDK
121
- * batch-merges every mid-turn user message into a single combined turn
122
- * once the in-flight turn finishes, so the daemon only ever observes
123
- * **one** init event per batch (no matter how many Feishu messages went
124
- * into the batch). Tracking a count + last-sender (rather than an
125
- * Array<msg>) keeps the daemon's view in sync with the SDK's actual
126
- * dequeue semantics. Empirically verified 2026-05-15 from the SDK's
127
- * `queue-operation` transcript events: 4 enqueues during a long turn
128
- * single dequeue at turn end one merged user message. Count is
129
- * decremented to 0 wholesale at the `init` boundary because the SDK
130
- * has already collapsed them into one turn. Distinguishes user-msg
131
- * turns from cron-fired scheduled wakeups: count > 0 user;
132
- * count === 0 scheduled (and `initCount > 1`). */
121
+ * USUALLY batch-merges every mid-turn user message into a single
122
+ * combined turn once the in-flight turn finishes, so most of the time
123
+ * the daemon observes **one** init event per batch. Tracking a count +
124
+ * last-sender (rather than an Array<msg>) keeps the daemon's view
125
+ * loosely in sync with the SDK's dequeue semantics. Caveat verified
126
+ * 2026-05-17 (test1 accumulator, 8-message rapid-fire): when the first
127
+ * write lands in an idle SDK, that single msg gets its own turn and
128
+ * the rest merge into a second turn i.e. 1+(N-1) split, not always
129
+ * one merge. To stay coherent with this, `drainMidTurnAndOpen` bumps
130
+ * the count by `batch.length` up front (covering both the first solo
131
+ * turn and the eventual merged tail), and the init handler resets to
132
+ * 0 on the first claim. If the SDK takes the merge path, the bail at
133
+ * `currentTurn=yes` in the init handler leaves pendingCount stale (>0)
134
+ * until the GC at the next `onUserMessage`; if it takes the split
135
+ * path, the second init sees pendingCount>0 and correctly classifies
136
+ * the trailing batch as user-batch (not a scheduled wakeup).
137
+ * Distinguishes user-msg turns from cron-fired scheduled
138
+ * wakeups: count > 0 ⇒ user; count === 0 ⇒ scheduled (and
139
+ * `initCount > 1`). */
133
140
  private pendingUserMessageCount = 0
134
141
  /** Mid-turn user messages buffered DAEMON-SIDE (not yet sendUserText'd
135
142
  * to the SDK). Drained in the `result` handler by writing each to SDK
@@ -1029,7 +1036,17 @@ export class Session {
1029
1036
  * calls wake the SDK polling loop (priority="now" semantics) and
1030
1037
  * comprise the input for the new turn. Opens the card here rather
1031
1038
  * than deferring to init because the init for this batch will arrive
1032
- * with `currentTurn` already set and bail. */
1039
+ * with `currentTurn` already set and bail.
1040
+ *
1041
+ * Each sendUserText also bumps `pendingUserMessageCount`. The SDK
1042
+ * USUALLY collapses our N writes into one merged turn, but **not
1043
+ * always** — empirically observed 2026-05-17, test1 accumulator
1044
+ * session: when the first write lands in an idle SDK (turn just
1045
+ * ended), the SDK eagerly starts a turn for that msg alone, then
1046
+ * merges the rest into a second turn. Without the bump here, that
1047
+ * second turn fires an `init` with `pendingUserMessageCount === 0`
1048
+ * and the init handler misclassifies it as a scheduled wakeup,
1049
+ * painting the `⏰ 触发` banner on what is really a user batch. */
1033
1050
  private async drainMidTurnAndOpen(): Promise<void> {
1034
1051
  if (this.pendingMidTurnMsgs.length === 0) return
1035
1052
  const batch = this.pendingMidTurnMsgs
@@ -1038,6 +1055,7 @@ export class Session {
1038
1055
  try {
1039
1056
  for (const msg of batch) {
1040
1057
  this.proc!.sendUserText(msg.wireText, msg.files)
1058
+ this.pendingUserMessageCount++
1041
1059
  if (msg.msgId) {
1042
1060
  const rid = this.pendingReactionIds.get(msg.msgId) ?? ''
1043
1061
  this.currentBatchReactionIds.set(msg.msgId, rid)