gestalt-mobile 0.17.1 → 0.17.3
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/README.md +4 -1
- package/dist/client/assets/{index-DgD-0I3M.js → index-Ba_4zoY1.js} +4 -4
- package/dist/client/index.html +1 -1
- package/dist/server/server/composition.js +41 -10
- package/dist/server/server/features/sessions/respond-interaction/endpoint.js +15 -2
- package/dist/server/server/features/skills/model/skill-profile.js +33 -2
- package/dist/server/server/platform/codex/server-request.js +9 -0
- package/dist/server/server/platform/codex/session-runtime.js +24 -15
- package/dist/server/server/platform/persistence/sqlite-pending-interaction-store.js +12 -2
- package/package.json +1 -1
package/dist/client/index.html
CHANGED
|
@@ -16,7 +16,7 @@ SPDX-License-Identifier: AGPL-3.0-or-later
|
|
|
16
16
|
<link rel="icon" href="/icons/gestalt-mobile-192.png" />
|
|
17
17
|
<link rel="apple-touch-icon" href="/icons/gestalt-mobile-180.png" />
|
|
18
18
|
<title>Gestalt Mobile</title>
|
|
19
|
-
<script type="module" crossorigin src="/assets/index-
|
|
19
|
+
<script type="module" crossorigin src="/assets/index-Ba_4zoY1.js"></script>
|
|
20
20
|
<link rel="stylesheet" crossorigin href="/assets/index-e2lJ5XqL.css">
|
|
21
21
|
</head>
|
|
22
22
|
<body>
|
|
@@ -32,7 +32,7 @@ import { GitSummaryCache } from './platform/git/git-summary-cache.js';
|
|
|
32
32
|
import { SessionSupervisor } from './platform/runtime/session-supervisor.js';
|
|
33
33
|
import { mapWithConcurrency } from './platform/runtime/concurrency.js';
|
|
34
34
|
import { RelaySession } from './features/sessions/model/relay-session.js';
|
|
35
|
-
import { toPendingInteraction } from './platform/codex/server-request.js';
|
|
35
|
+
import { resolvedServerRequestId, toPendingInteraction } from './platform/codex/server-request.js';
|
|
36
36
|
import { isValidInteractionResponse, isValidQuizInteractionResponse, } from './features/sessions/interaction/response-validator.js';
|
|
37
37
|
import { promoteRecentThread } from './features/sessions/promote-recent-thread/use-case.js';
|
|
38
38
|
import { FilesystemSkillProfileStore } from './platform/skills/filesystem-skill-profile-store.js';
|
|
@@ -113,9 +113,25 @@ export async function composeRelayApp(options) {
|
|
|
113
113
|
const gitSummaries = new GitSummaryCache(inspectGit);
|
|
114
114
|
let recoverExitedSession = () => { };
|
|
115
115
|
let planMeasurementRefresh;
|
|
116
|
+
const publishInteractionResolved = (sessionId, requestId, occurredAt, outcome) => {
|
|
117
|
+
const interaction = interactions
|
|
118
|
+
.snapshot(sessionId)
|
|
119
|
+
.find((item) => item.requestId === requestId);
|
|
120
|
+
events.publish(journal.append(sessionId, 'interaction.resolved', { requestId, turnId: interaction?.turnId ?? null, resolvedAt: occurredAt, outcome }, occurredAt));
|
|
121
|
+
};
|
|
122
|
+
const dismissPendingInteractions = (sessionId, occurredAt) => {
|
|
123
|
+
for (const interaction of interactions.list(sessionId)) {
|
|
124
|
+
if (interactions.resolve(sessionId, interaction.requestId, occurredAt, 'dismissed'))
|
|
125
|
+
publishInteractionResolved(sessionId, interaction.requestId, occurredAt, 'dismissed');
|
|
126
|
+
}
|
|
127
|
+
};
|
|
116
128
|
const runtime = options.startAppServers
|
|
117
129
|
? new CodexSessionRuntime(options.launchAppServer ?? launchCodexAppServer, undefined, (sessionId, notification) => {
|
|
118
130
|
const occurredAt = new Date().toISOString();
|
|
131
|
+
const resolvedRequestId = resolvedServerRequestId(notification);
|
|
132
|
+
if (resolvedRequestId &&
|
|
133
|
+
interactions.resolve(sessionId, resolvedRequestId, occurredAt, 'dismissed'))
|
|
134
|
+
publishInteractionResolved(sessionId, resolvedRequestId, occurredAt, 'dismissed');
|
|
119
135
|
const currentSession = sessions.find(sessionId);
|
|
120
136
|
const normalized = normalizeCodexNotification(sessionId, 0, occurredAt, notification, currentSession?.workspacePath, currentSession?.activeTurnId);
|
|
121
137
|
if (!normalized)
|
|
@@ -148,7 +164,10 @@ export async function composeRelayApp(options) {
|
|
|
148
164
|
sessions.save(updated);
|
|
149
165
|
events.publish(journal.append(sessionId, 'interaction.requested', interaction, updated.updatedAt));
|
|
150
166
|
return true;
|
|
151
|
-
}, (sessionId) =>
|
|
167
|
+
}, (sessionId) => {
|
|
168
|
+
dismissPendingInteractions(sessionId, new Date().toISOString());
|
|
169
|
+
recoverExitedSession(sessionId);
|
|
170
|
+
}, resolveSkills, planStatusSource, (sessionId, update) => {
|
|
152
171
|
supervisedPlans.accept(sessionId, update);
|
|
153
172
|
planMeasurementRefresh?.accept(sessionId, update);
|
|
154
173
|
if (update.kind === 'updated') {
|
|
@@ -291,15 +310,27 @@ export async function composeRelayApp(options) {
|
|
|
291
310
|
skillCatalog,
|
|
292
311
|
defaultSkillProfile: options.explicitSkillProfile,
|
|
293
312
|
activate: runtime
|
|
294
|
-
? async (session, settings) =>
|
|
313
|
+
? async (session, settings) => {
|
|
314
|
+
const now = new Date().toISOString();
|
|
315
|
+
dismissPendingInteractions(session.id, now);
|
|
316
|
+
return runtime.start(session, now, settings);
|
|
317
|
+
}
|
|
295
318
|
: undefined,
|
|
296
319
|
startTurn: runtime
|
|
297
320
|
? async (session, text, clientUserMessageId) => runtime.startTurn(session, text, clientUserMessageId, new Date().toISOString())
|
|
298
321
|
: undefined,
|
|
299
322
|
ensureWriter: runtime
|
|
300
|
-
? (session) =>
|
|
323
|
+
? (session) => {
|
|
324
|
+
dismissPendingInteractions(session.id, new Date().toISOString());
|
|
325
|
+
return runtime.ensureWriter(session, new Date().toISOString());
|
|
326
|
+
}
|
|
327
|
+
: undefined,
|
|
328
|
+
releaseWriter: runtime
|
|
329
|
+
? (id) => {
|
|
330
|
+
dismissPendingInteractions(id, new Date().toISOString());
|
|
331
|
+
return runtime.release(id);
|
|
332
|
+
}
|
|
301
333
|
: undefined,
|
|
302
|
-
releaseWriter: runtime ? (id) => runtime.release(id) : undefined,
|
|
303
334
|
onTurnStarted: (session) => planMeasurementRefresh?.refreshNow(session.id),
|
|
304
335
|
models,
|
|
305
336
|
readHistory: runtime ? (session) => runtime.readHistory(session) : undefined,
|
|
@@ -325,17 +356,15 @@ export async function composeRelayApp(options) {
|
|
|
325
356
|
close: runtime
|
|
326
357
|
? (id) => {
|
|
327
358
|
planMeasurementRefresh?.stop(id);
|
|
359
|
+
dismissPendingInteractions(id, new Date().toISOString());
|
|
328
360
|
return runtime.release(id);
|
|
329
361
|
}
|
|
330
362
|
: undefined,
|
|
331
363
|
replyInteraction: runtime
|
|
332
|
-
? (sessionId, requestId, value) => runtime.resolveServerRequest(sessionId, requestId, value)
|
|
364
|
+
? (sessionId, requestId, value) => runtime.resolveServerRequest(sessionId, requestId, value) ? 'accepted' : 'cleared'
|
|
333
365
|
: undefined,
|
|
334
366
|
interactionResolved: (sessionId, requestId, occurredAt, outcome) => {
|
|
335
|
-
|
|
336
|
-
.snapshot(sessionId)
|
|
337
|
-
.find((item) => item.requestId === requestId);
|
|
338
|
-
events.publish(journal.append(sessionId, 'interaction.resolved', { requestId, turnId: interaction?.turnId ?? null, resolvedAt: occurredAt, outcome }, occurredAt));
|
|
367
|
+
publishInteractionResolved(sessionId, requestId, occurredAt, outcome);
|
|
339
368
|
},
|
|
340
369
|
},
|
|
341
370
|
sessionEvents: {
|
|
@@ -438,6 +467,8 @@ export async function composeRelayApp(options) {
|
|
|
438
467
|
});
|
|
439
468
|
app.addHook('onClose', async () => {
|
|
440
469
|
planMeasurementRefresh?.stopAll();
|
|
470
|
+
for (const session of sessions.list())
|
|
471
|
+
dismissPendingInteractions(session.id, new Date().toISOString());
|
|
441
472
|
runtime?.stopAll();
|
|
442
473
|
planStatusSource.closeAll();
|
|
443
474
|
database.close();
|
|
@@ -18,8 +18,21 @@ export function registerRespondInteraction(app, deps) {
|
|
|
18
18
|
return reply.code(409).send({ code: 'INTERACTION_NOT_PENDING' });
|
|
19
19
|
if (deps.validate && !deps.validate(id, requestId, value))
|
|
20
20
|
return reply.code(400).send({ code: 'INTERACTION_RESPONSE_INVALID' });
|
|
21
|
-
|
|
22
|
-
|
|
21
|
+
const delivery = deps.reply(id, requestId, value);
|
|
22
|
+
if (delivery === false || delivery === 'unavailable')
|
|
23
|
+
return reply.code(409).send({ code: 'INTERACTION_DELIVERY_UNAVAILABLE' });
|
|
24
|
+
if (delivery === 'cleared') {
|
|
25
|
+
const resolvedAt = deps.now();
|
|
26
|
+
const outcome = 'dismissed';
|
|
27
|
+
if (deps.resolve(id, requestId, resolvedAt, outcome)) {
|
|
28
|
+
deps.resolved?.(id, requestId, resolvedAt, outcome);
|
|
29
|
+
return reply.code(202).send({ accepted: true, resolvedAt, outcome });
|
|
30
|
+
}
|
|
31
|
+
const raced = deps.alreadyResolved?.(id, requestId);
|
|
32
|
+
return raced
|
|
33
|
+
? reply.code(202).send({ accepted: true, ...raced })
|
|
34
|
+
: reply.code(409).send({ code: 'INTERACTION_NOT_PENDING' });
|
|
35
|
+
}
|
|
23
36
|
const resolvedAt = deps.now();
|
|
24
37
|
const outcome = safeOutcome(value);
|
|
25
38
|
if (!deps.resolve(id, requestId, resolvedAt, outcome))
|
|
@@ -81,6 +81,36 @@ function canonicalSkillPath(value) {
|
|
|
81
81
|
}
|
|
82
82
|
return value;
|
|
83
83
|
}
|
|
84
|
+
/**
|
|
85
|
+
* Return the durable identity of a skill inside Codex's versioned plugin cache.
|
|
86
|
+
* The concrete version directory is installation state, while marketplace,
|
|
87
|
+
* plugin, and skill-relative path remain stable across plugin upgrades.
|
|
88
|
+
*/
|
|
89
|
+
function versionNeutralPluginSkillPath(value) {
|
|
90
|
+
const match = value.match(/^(.*[\\/]plugins[\\/]cache[\\/][^\\/]+[\\/][^\\/]+)[\\/][^\\/]+([\\/]skills[\\/].+[\\/]SKILL\.md)$/);
|
|
91
|
+
return match ? `${match[1]}${match[2]}` : undefined;
|
|
92
|
+
}
|
|
93
|
+
function rebindVersionedPluginSkills(discovered, selection) {
|
|
94
|
+
const discoveredPaths = new Set(discovered.map((skill) => canonicalSkillPath(skill.path)));
|
|
95
|
+
const discoveredByDurablePath = new Map();
|
|
96
|
+
for (const skill of discovered) {
|
|
97
|
+
const durablePath = versionNeutralPluginSkillPath(canonicalSkillPath(skill.path));
|
|
98
|
+
if (durablePath === undefined)
|
|
99
|
+
continue;
|
|
100
|
+
const matches = discoveredByDurablePath.get(durablePath) ?? [];
|
|
101
|
+
matches.push(skill);
|
|
102
|
+
discoveredByDurablePath.set(durablePath, matches);
|
|
103
|
+
}
|
|
104
|
+
return createSkillSelection(selection.map((entry) => {
|
|
105
|
+
if (discoveredPaths.has(entry.path))
|
|
106
|
+
return entry;
|
|
107
|
+
const durablePath = versionNeutralPluginSkillPath(entry.path);
|
|
108
|
+
if (durablePath === undefined)
|
|
109
|
+
return entry;
|
|
110
|
+
const matches = discoveredByDurablePath.get(durablePath) ?? [];
|
|
111
|
+
return matches.length === 1 ? { ...entry, path: matches[0].path } : entry;
|
|
112
|
+
}));
|
|
113
|
+
}
|
|
84
114
|
/**
|
|
85
115
|
* Validate a complete selection and return its canonical deterministic order.
|
|
86
116
|
* This is lexical only: resolving symlinks is I/O and belongs to a platform
|
|
@@ -137,11 +167,12 @@ export function compileSkillOverride(input) {
|
|
|
137
167
|
const effective = selectEffectiveSkillSelection(input);
|
|
138
168
|
if (effective.selection === undefined)
|
|
139
169
|
return { source: 'native', skillsConfig: undefined, warnings: [] };
|
|
170
|
+
const reboundSelection = rebindVersionedPluginSkills(input.discovered, effective.selection);
|
|
140
171
|
const discoveredPaths = new Set(input.discovered.map((skill) => canonicalSkillPath(skill.path)));
|
|
141
|
-
const warnings =
|
|
172
|
+
const warnings = reboundSelection
|
|
142
173
|
.filter((entry) => !discoveredPaths.has(entry.path))
|
|
143
174
|
.map((entry) => `Saved skill path is no longer discovered: ${entry.path}`);
|
|
144
|
-
const configured = applySkillSelectionSnapshot(input.discovered,
|
|
175
|
+
const configured = applySkillSelectionSnapshot(input.discovered, reboundSelection);
|
|
145
176
|
return {
|
|
146
177
|
source: effective.source,
|
|
147
178
|
skillsConfig: configured
|
|
@@ -21,6 +21,15 @@ export function toPendingInteraction(input) {
|
|
|
21
21
|
}[input.method];
|
|
22
22
|
return kind ? { requestId: String(input.id), kind, payload: input.params } : null;
|
|
23
23
|
}
|
|
24
|
+
export function resolvedServerRequestId(input) {
|
|
25
|
+
if (input.method !== 'serverRequest/resolved' || !isRecord(input.params))
|
|
26
|
+
return null;
|
|
27
|
+
const requestId = input.params.requestId;
|
|
28
|
+
if (!((typeof requestId === 'string' && requestId.length > 0 && requestId.length <= 256) ||
|
|
29
|
+
(typeof requestId === 'number' && Number.isSafeInteger(requestId))))
|
|
30
|
+
return null;
|
|
31
|
+
return String(requestId);
|
|
32
|
+
}
|
|
24
33
|
function isRecord(value) {
|
|
25
34
|
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
26
35
|
}
|
|
@@ -11,6 +11,7 @@ import { gestaltQuizDynamicTool } from '../../../shared/contracts/quiz.js';
|
|
|
11
11
|
import { threadPlanName } from './thread-plan-name.js';
|
|
12
12
|
import { WriterAcquisitionError, } from '../../features/sessions/application/writer-acquisition.js';
|
|
13
13
|
import { isCodexThreadWriterBusy, isMissingCodexThreadRollout } from './json-rpc-client.js';
|
|
14
|
+
import { resolvedServerRequestId } from './server-request.js';
|
|
14
15
|
/** One private owner for every resource acquired for a live Codex child. */
|
|
15
16
|
class SessionResource {
|
|
16
17
|
sessionId;
|
|
@@ -38,7 +39,6 @@ class SessionResource {
|
|
|
38
39
|
return false;
|
|
39
40
|
this.disposed = true;
|
|
40
41
|
for (const pending of this.pendingRequests.values()) {
|
|
41
|
-
clearTimeout(pending.timer);
|
|
42
42
|
pending.reject(new Error('CODEX_SERVER_REQUEST_CANCELLED'));
|
|
43
43
|
}
|
|
44
44
|
this.pendingRequests.clear();
|
|
@@ -65,13 +65,14 @@ export class CodexSessionRuntime {
|
|
|
65
65
|
planStatusSource;
|
|
66
66
|
onPlanStatus;
|
|
67
67
|
planMeasurementBaseUrl;
|
|
68
|
-
requestTimeoutMs;
|
|
69
68
|
maxPendingRequests;
|
|
70
69
|
readerCwd;
|
|
71
70
|
constructor(launch,
|
|
72
71
|
// Kept as an ignored compatibility slot while callers migrate from the old
|
|
73
72
|
// correlated-map constructor shape. Live ownership is `sessions` only.
|
|
74
|
-
_legacyProcesses = undefined, onNotification, onServerRequest, onProcessExit, resolveSkills, planStatusSource, onPlanStatus, planMeasurementBaseUrl,
|
|
73
|
+
_legacyProcesses = undefined, onNotification, onServerRequest, onProcessExit, resolveSkills, planStatusSource, onPlanStatus, planMeasurementBaseUrl,
|
|
74
|
+
// Compatibility slot: blocking app-server requests wait for explicit input.
|
|
75
|
+
_legacyRequestTimeoutMs = undefined, maxPendingRequests = 64, readerCwd) {
|
|
75
76
|
this.launch = launch;
|
|
76
77
|
this.onNotification = onNotification;
|
|
77
78
|
this.onServerRequest = onServerRequest;
|
|
@@ -80,10 +81,10 @@ export class CodexSessionRuntime {
|
|
|
80
81
|
this.planStatusSource = planStatusSource;
|
|
81
82
|
this.onPlanStatus = onPlanStatus;
|
|
82
83
|
this.planMeasurementBaseUrl = planMeasurementBaseUrl;
|
|
83
|
-
this.requestTimeoutMs = requestTimeoutMs;
|
|
84
84
|
this.maxPendingRequests = maxPendingRequests;
|
|
85
85
|
this.readerCwd = readerCwd;
|
|
86
86
|
void _legacyProcesses;
|
|
87
|
+
void _legacyRequestTimeoutMs;
|
|
87
88
|
}
|
|
88
89
|
sessions = new Map();
|
|
89
90
|
historyReads = new Map();
|
|
@@ -138,7 +139,6 @@ export class CodexSessionRuntime {
|
|
|
138
139
|
if (!resource || !pending)
|
|
139
140
|
return false;
|
|
140
141
|
resource.pendingRequests.delete(requestId);
|
|
141
|
-
clearTimeout(pending.timer);
|
|
142
142
|
pending.resolve(result);
|
|
143
143
|
return true;
|
|
144
144
|
}
|
|
@@ -337,18 +337,17 @@ export class CodexSessionRuntime {
|
|
|
337
337
|
}
|
|
338
338
|
}
|
|
339
339
|
holdServerRequest(resource, request) {
|
|
340
|
-
if (!resource.active
|
|
341
|
-
return Promise.reject(new Error('
|
|
342
|
-
}
|
|
340
|
+
if (!resource.active)
|
|
341
|
+
return Promise.reject(new Error('CODEX_SERVER_REQUEST_CANCELLED'));
|
|
343
342
|
if (resource.pendingRequests.size >= this.maxPendingRequests)
|
|
344
343
|
return Promise.reject(new Error('CODEX_SERVER_REQUEST_LIMIT'));
|
|
345
344
|
const requestId = String(request.id);
|
|
345
|
+
if (resource.pendingRequests.has(requestId))
|
|
346
|
+
return Promise.reject(new Error('CODEX_SERVER_REQUEST_DUPLICATE'));
|
|
347
|
+
if (!this.onServerRequest?.(resource.sessionId, request))
|
|
348
|
+
return Promise.reject(new Error('CODEX_SERVER_REQUEST_UNSUPPORTED'));
|
|
346
349
|
return new Promise((resolve, reject) => {
|
|
347
|
-
|
|
348
|
-
if (resource.pendingRequests.delete(requestId))
|
|
349
|
-
reject(new Error('CODEX_SERVER_REQUEST_TIMEOUT'));
|
|
350
|
-
}, this.requestTimeoutMs);
|
|
351
|
-
resource.pendingRequests.set(requestId, { resolve, reject, timer });
|
|
350
|
+
resource.pendingRequests.set(requestId, { resolve, reject });
|
|
352
351
|
});
|
|
353
352
|
}
|
|
354
353
|
async startThread(process, session, settings = {}) {
|
|
@@ -394,8 +393,18 @@ export class CodexSessionRuntime {
|
|
|
394
393
|
this.sessions.delete(session.id);
|
|
395
394
|
});
|
|
396
395
|
const notificationUnsubscribe = process.rpc.onNotification((notification) => {
|
|
397
|
-
if (resource.active)
|
|
398
|
-
|
|
396
|
+
if (!resource.active)
|
|
397
|
+
return;
|
|
398
|
+
const resolvedRequestId = resolvedServerRequestId(notification);
|
|
399
|
+
if (resolvedRequestId) {
|
|
400
|
+
const pending = resource.pendingRequests.get(resolvedRequestId);
|
|
401
|
+
resource.pendingRequests.delete(resolvedRequestId);
|
|
402
|
+
// App-server has already cleared the request, so settle the local handler too.
|
|
403
|
+
// The late JSON-RPC error response is harmless and prevents an orphaned promise.
|
|
404
|
+
// The notification callback reconciles the durable interaction record.
|
|
405
|
+
pending?.reject(new Error('CODEX_SERVER_REQUEST_CLEARED'));
|
|
406
|
+
}
|
|
407
|
+
this.onNotification?.(session.id, notification);
|
|
399
408
|
});
|
|
400
409
|
const requestUnsubscribe = process.rpc.onServerRequest((request) => this.holdServerRequest(resource, request));
|
|
401
410
|
const exitUnsubscribe = process.onExit?.(() => {
|
|
@@ -4,7 +4,9 @@
|
|
|
4
4
|
* SPDX-License-Identifier: AGPL-3.0-or-later
|
|
5
5
|
*/
|
|
6
6
|
function safeInteractionOutcome(value) {
|
|
7
|
-
return value === 'approved' || value === 'denied' || value === 'answered'
|
|
7
|
+
return value === 'approved' || value === 'denied' || value === 'answered' || value === 'dismissed'
|
|
8
|
+
? value
|
|
9
|
+
: 'answered';
|
|
8
10
|
}
|
|
9
11
|
export class SqlitePendingInteractionStore {
|
|
10
12
|
db;
|
|
@@ -13,7 +15,15 @@ export class SqlitePendingInteractionStore {
|
|
|
13
15
|
}
|
|
14
16
|
add(sessionId, interaction) {
|
|
15
17
|
this.db
|
|
16
|
-
.prepare(
|
|
18
|
+
.prepare(`INSERT INTO pending_interactions (session_id,request_id,kind,payload_json,turn_id,requested_at)
|
|
19
|
+
VALUES (?,?,?,?,?,?)
|
|
20
|
+
ON CONFLICT(session_id,request_id) DO UPDATE SET
|
|
21
|
+
kind = excluded.kind,
|
|
22
|
+
payload_json = excluded.payload_json,
|
|
23
|
+
turn_id = excluded.turn_id,
|
|
24
|
+
requested_at = excluded.requested_at,
|
|
25
|
+
resolved_at = NULL,
|
|
26
|
+
outcome = NULL`)
|
|
17
27
|
.run(sessionId, interaction.requestId, interaction.kind, JSON.stringify(interaction.payload), interaction.turnId ?? null, interaction.requestedAt ?? null);
|
|
18
28
|
}
|
|
19
29
|
resolve(sessionId, requestId, resolvedAt, outcome = 'answered') {
|