ftown-bridge 0.19.17 → 0.19.19

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.
@@ -65,6 +65,11 @@ export function registerFtownOpencodePlugin(client, options = {}) {
65
65
  // The opencode session id this plugin instance tracks — updated from every
66
66
  // event that carries one, so it stays correct across TUI session switches.
67
67
  let currentSessionId;
68
+ // Serializes turn-end handling: opencode may emit BOTH session.status(idle)
69
+ // and session.idle for the same boundary. Unordered, two concurrent drains
70
+ // could read the same undelivered mail before either marks it delivered and
71
+ // inject it twice.
72
+ let turnEndChain = Promise.resolve();
68
73
 
69
74
  async function request(path, init = {}) {
70
75
  const pointer = await readBridgePointer();
@@ -98,10 +103,10 @@ export function registerFtownOpencodePlugin(client, options = {}) {
98
103
  });
99
104
  }
100
105
 
101
- async function drainMail() {
106
+ async function listInbox(peek) {
102
107
  if (!ftownSessionId) return [];
103
108
  const response = await request(
104
- `/api/sessions/${encodeURIComponent(ftownSessionId)}/inbox?wait=0`,
109
+ `/api/sessions/${encodeURIComponent(ftownSessionId)}/inbox?wait=0${peek ? '&peek=1' : ''}`,
105
110
  { method: 'GET' },
106
111
  );
107
112
  if (!response) return [];
@@ -113,12 +118,18 @@ export function registerFtownOpencodePlugin(client, options = {}) {
113
118
  }
114
119
  }
115
120
 
121
+ /**
122
+ * Peek first, and only mark delivered AFTER the prompt was accepted. If
123
+ * submission fails, mail stays queued for the next boundary instead of
124
+ * being silently lost between a marking drain and a failed injection.
125
+ */
116
126
  async function deliverMail() {
117
- const messages = await drainMail();
118
- if (messages.length === 0 || !currentSessionId) return;
127
+ if (!currentSessionId) return;
128
+ const messages = await listInbox(true);
129
+ if (messages.length === 0) return;
119
130
  const formatted = messages.map(formatMail).join('\n');
120
- // Submitting a prompt starts a new turn; when it finishes, session.idle
121
- // fires again and any mail that arrived meanwhile is delivered then.
131
+ // Submitting a prompt starts a new turn; when it finishes, the next idle
132
+ // boundary fires and any mail that arrived meanwhile is delivered then.
122
133
  await client.session.prompt({
123
134
  path: { id: currentSessionId },
124
135
  body: {
@@ -132,17 +143,39 @@ export function registerFtownOpencodePlugin(client, options = {}) {
132
143
  ],
133
144
  },
134
145
  });
146
+ // Accepted — only now let the bridge mark the peeked messages delivered.
147
+ await listInbox(false);
135
148
  }
136
149
 
137
- function trackSessionId(properties) {
138
- const id = properties?.info?.id ?? properties?.sessionID ?? properties?.info?.sessionID;
150
+ /**
151
+ * Extract the opencode SESSION id from event properties. `info.id` is only
152
+ * trustworthy on session.created — on message.updated it is a MESSAGE id
153
+ * (msg_…), which must never be stored as the resume id.
154
+ */
155
+ function trackSessionId(properties, preferInfoId = false) {
156
+ const id = properties?.sessionID
157
+ ?? properties?.info?.sessionID
158
+ ?? (preferInfoId ? properties?.info?.id : undefined);
139
159
  if (typeof id === 'string' && id.trim()) currentSessionId = id.trim();
140
160
  }
141
161
 
162
+ /** Turn boundary: mark idle, then drain and inject pending mail. */
163
+ function endTurn(properties) {
164
+ trackSessionId(properties);
165
+ const run = turnEndChain.then(async () => {
166
+ await postHook('Stop');
167
+ await deliverMail();
168
+ }).catch(() => {
169
+ // A failed hook POST or mail delivery must never crash the agent TUI.
170
+ });
171
+ turnEndChain = run;
172
+ return run;
173
+ }
174
+
142
175
  async function handleEvent(event) {
143
176
  switch (event.type) {
144
177
  case 'session.created':
145
- trackSessionId(event.properties);
178
+ trackSessionId(event.properties, true);
146
179
  await postHook('SessionStart');
147
180
  break;
148
181
  case 'message.updated': {
@@ -152,10 +185,17 @@ export function registerFtownOpencodePlugin(client, options = {}) {
152
185
  break;
153
186
  }
154
187
  case 'session.idle':
188
+ await endTurn(event.properties);
189
+ break;
190
+ case 'session.status': {
155
191
  trackSessionId(event.properties);
156
- await postHook('Stop');
157
- await deliverMail();
192
+ // v1.18.x fires session.status(busy/idle) reliably; session.idle may
193
+ // not reach plugins at all. Busy keeps the mail pump from nudging
194
+ // mid-turn; idle is a full turn boundary.
195
+ if (event.properties?.status?.type === 'busy') await postHook('PreToolUse');
196
+ else if (event.properties?.status?.type === 'idle') await endTurn(event.properties);
158
197
  break;
198
+ }
159
199
  case 'session.error':
160
200
  trackSessionId(event.properties);
161
201
  await postHook('SessionEnd', { reason: 'error' });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ftown-bridge",
3
- "version": "0.19.17",
3
+ "version": "0.19.19",
4
4
  "description": "CLI bridge for ftown — generic PTY-over-Centrifugo relay",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",