@goodea/olimpyx 0.1.1 → 0.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.
package/src/cli.js CHANGED
@@ -10,10 +10,29 @@ import { addAgentFromCatalog, readOwnerStatus } from './init-apply.js';
10
10
  import { runInit } from './init.js';
11
11
  import { searchCharacters } from './characters.js';
12
12
  import { ownerHome, readVault } from './vault.js';
13
+ import { HOST_SKILL_DIRS, installStarterSkill } from './skill-install.js';
14
+ import { resolveParticipantHome } from './participant-home.js';
13
15
 
14
16
  const args = process.argv.slice(2);
15
17
  const command = args.shift();
16
- const state = new LocalState(resolve(process.env.OLIMPYX_HOME || '.olimpyx'));
18
+
19
+ // The participant home is resolved on first use, not at startup: owner-scoped commands
20
+ // (`init`, `status`, `agent add`, `skill`) never needed one and must keep working from any
21
+ // directory. Only a command that actually reaches for participant state gets the refusal.
22
+ // See participant-home.js.
23
+ let participantState = null;
24
+ function participantHome() {
25
+ const { home, reason } = resolveParticipantHome();
26
+ if (!home) throw new Error(reason);
27
+ return home;
28
+ }
29
+ const state = new Proxy({}, {
30
+ get(_target, property) {
31
+ participantState ??= new LocalState(participantHome());
32
+ const value = Reflect.get(participantState, property);
33
+ return typeof value === 'function' ? value.bind(participantState) : value;
34
+ }
35
+ });
17
36
 
18
37
  function option(name, fallback) {
19
38
  const index = args.indexOf(`--${name}`);
@@ -38,18 +57,27 @@ async function configuredClient(tokenOverride, credential = 'session') {
38
57
  }
39
58
  async function activeClient(callerId) {
40
59
  if (!callerId) throw new Error('--caller-id is required for participant commands');
41
- const local = await state.loadSession(); if (!local) throw new Error('No local session. Run session begin first.');
60
+ const local = await state.loadSession(callerId); if (!local) throw new Error('No local session. Run session begin first.');
42
61
  await state.renewSession(callerId);
43
62
  const client = await configuredClient(local.token);
44
63
  const heartbeat = await client.request('POST', `/v1/sessions/${encodeURIComponent(local.session_id)}/heartbeat`, { observed_at: new Date().toISOString() });
45
64
  return { client, local, heartbeat };
46
65
  }
47
- async function mutation(client, method, path, body, explicitKey) {
48
- const pending = await state.beginMutation(method, path, body, explicitKey);
66
+ async function mutation(client, method, path, body, explicitKey, callerId) {
67
+ const pending = await state.beginMutation(method, path, body, explicitKey, callerId);
49
68
  const result = await client.request(method, path, body, { headers: { 'idempotency-key': pending.key } });
50
- await state.completeMutation(pending.fingerprint);
69
+ await state.completeMutation(pending.fingerprint, callerId);
51
70
  return result;
52
71
  }
72
+ // Best-effort activity broadcast; never fail the caller's command on a
73
+ // transient server error. Used to keep the city UI showing where this agent is.
74
+ async function broadcastActivity(callerId, payload) {
75
+ if (!callerId) return;
76
+ try {
77
+ const { client } = await activeClient(callerId);
78
+ await client.request('POST', '/v1/sessions/me/activity', payload);
79
+ } catch { /* presence/activity are advisory; never block the primary action */ }
80
+ }
53
81
  async function loadVaultOwnerToken() {
54
82
  try { return (await readVault()).owner?.access_token ?? null; } catch { return null; }
55
83
  }
@@ -59,10 +87,28 @@ async function tryLoadOwnerToken() {
59
87
  return loadVaultOwnerToken();
60
88
  }
61
89
  async function ownerServerUrl() {
62
- const local = await state.loadConfig();
90
+ // A participant home may be unavailable here -- an owner command run without
91
+ // OLIMPYX_PARTICIPANT or OLIMPYX_HOME. That is not this function's problem: it only means
92
+ // there is no participant-local config to prefer, so fall through to the owner home.
93
+ let local = {};
94
+ try { local = await state.loadConfig(); } catch { /* no participant home; owner config below */ }
63
95
  if (local.serverUrl) return local.serverUrl;
64
96
  try { return JSON.parse(await readFile(join(ownerHome(), 'config.json'), 'utf8')).serverUrl; } catch { return null; }
65
97
  }
98
+
99
+ // Every owner-scoped client goes through here. Resolving the server from `state` alone made
100
+ // these commands depend on which participant home was selected. After a global `init` the
101
+ // owner config lives in ~/.olimpyx, so with a participant home pointing elsewhere the URL came
102
+ // back undefined and the client constructor died on `undefined.replace`; worse, a participant
103
+ // home configured against a DIFFERENT server sent the owner's real token there and the server
104
+ // answered 401 "Invalid or expired credential" -- a message that points at the token when the
105
+ // token was never the problem. ownerServerUrl() keeps the participant-local config first and
106
+ // falls back to the owner home, which is what `usage` already did and the rest did not.
107
+ async function ownerClientWith(token) {
108
+ const serverUrl = await ownerServerUrl();
109
+ if (!serverUrl) throw new Error('Not configured. Run: olimpyx init (or olimpyx configure --server URL)');
110
+ return new OlimpyxClient({ serverUrl, token });
111
+ }
66
112
  // Resolves this agent's own owner id for the local budget's owner-scoping (budget.js
67
113
  // isOwnTaskRoom/evaluateHelpPolicy, PRD §3.4): `enroll`/`owner-login` normally already
68
114
  // cache it in config.json, so this is usually a plain local read with no network call.
@@ -75,7 +121,7 @@ async function resolveOwnerId(config) {
75
121
  const ownerToken = await tryLoadOwnerToken();
76
122
  if (!ownerToken) return null;
77
123
  try {
78
- const client = new OlimpyxClient({ serverUrl: config.serverUrl, token: ownerToken });
124
+ const client = await ownerClientWith(ownerToken);
79
125
  const me = await client.request('GET', '/v1/owners/me');
80
126
  const ownerId = me?.data?.owner_id ?? null;
81
127
  if (ownerId) await state.saveConfig({ ...config, ownerId });
@@ -86,10 +132,8 @@ async function resolveOwnerId(config) {
86
132
  }
87
133
  async function requireOwnerClient() {
88
134
  const ownerToken = await tryLoadOwnerToken();
89
- const serverUrl = await ownerServerUrl();
90
- if (!serverUrl) throw new Error('Not configured. Run: olimpyx init');
91
135
  if (!ownerToken) throw new Error('No owner credential. Run olimpyx init or owner-login.');
92
- return new OlimpyxClient({ serverUrl, token: ownerToken });
136
+ return ownerClientWith(ownerToken);
93
137
  }
94
138
  // Best-effort so the server can prune acknowledged inbox events (PRD §3.3); a failure
95
139
  // here must never interrupt the caller, which has already persisted the cursor locally.
@@ -129,8 +173,14 @@ async function parseJsonOrList(value) {
129
173
  }
130
174
 
131
175
  async function main() {
176
+ if (command === 'resident') {
177
+ const { runResidentCli } = await import('./resident/cli.mjs');
178
+ await runResidentCli(args);
179
+ return;
180
+ }
132
181
  if (command === 'init') {
133
- await runInit();
182
+ const force = option('force', false) === true;
183
+ await runInit({ force });
134
184
  return;
135
185
  }
136
186
  if (command === 'status') {
@@ -138,9 +188,38 @@ async function main() {
138
188
  return;
139
189
  }
140
190
  if (command === 'skill') {
191
+ const sub = args.shift();
192
+ if (sub === '--update' || sub === 'update') {
193
+ const host = option('host', 'codex');
194
+ if (!HOST_SKILL_DIRS[host]) throw new Error(`Unknown host "${host}". Use one of: ${Object.keys(HOST_SKILL_DIRS).join(', ')}`);
195
+ const projectPath = option('project') || process.cwd();
196
+ const target = await installStarterSkill(host, { scope: 'local', projectPath });
197
+ output({ updated: true, host, target });
198
+ return;
199
+ }
200
+ if (sub === '--host' || sub === '--path') {
201
+ process.stderr.write(`Usage: olimpyx skill --update [--host codex|claude|claude_code|cursor|opencode] [--project PATH]\n`);
202
+ process.exitCode = 2; return;
203
+ }
141
204
  process.stdout.write(await readFile(join(ownerHome(), 'skill.md'), 'utf8'));
142
205
  return;
143
206
  }
207
+ if (command === 'activity') {
208
+ // Explicit activity declaration (F-02). The interactive `init` wizard
209
+ // does its own enrollment and never needs this; this command is for
210
+ // the non-interactive / scripted path or for re-declaring a location
211
+ // mid-session.
212
+ const sub = args.shift();
213
+ const callerId = option('caller-id');
214
+ if (sub !== 'set') throw new Error('activity actions: set --kind <room|knowledge|lobby|inbox|offline> [--room-id ID] [--knowledge-card-id ID] [--note TEXT]');
215
+ const kind = option('kind'); const roomId = option('room-id'); const knowledgeCardId = option('knowledge-card-id'); const note = option('note') ?? '';
216
+ if (!kind) throw new Error('--kind is required');
217
+ const payload = { kind, note };
218
+ if (kind === 'room') { if (!roomId) throw new Error('--room-id is required when --kind=room'); payload.room_id = roomId; }
219
+ if (kind === 'knowledge') { if (!knowledgeCardId) throw new Error('--knowledge-card-id is required when --kind=knowledge'); payload.knowledge_card_id = knowledgeCardId; }
220
+ const { client } = await activeClient(callerId);
221
+ output(await client.request('POST', '/v1/sessions/me/activity', payload)); return;
222
+ }
144
223
  if (command === 'configure') {
145
224
  const serverUrl = option('server'); if (!serverUrl) throw new Error('--server URL is required');
146
225
  const current = await state.loadConfig(); output(await state.saveConfig({ ...current, serverUrl })); return;
@@ -150,7 +229,7 @@ async function main() {
150
229
  const password = process.env.OLIMPYX_OWNER_PASSWORD || (option('password-stdin') ? await stdin() : null);
151
230
  if (!email || !password) throw new Error('Use --email and either --password-stdin or OLIMPYX_OWNER_PASSWORD');
152
231
  const config = await state.loadConfig();
153
- const client = new OlimpyxClient({ serverUrl: config.serverUrl, token: null });
232
+ const client = await ownerClientWith(null);
154
233
  const result = await client.request('POST', '/v1/owners/login', { email, password });
155
234
  await state.init(); await state.saveOwnerCredential(result.data.access_token);
156
235
  // Cache this agent's owner id locally (budget.js isOwnTaskRoom/evaluateHelpPolicy
@@ -172,7 +251,7 @@ async function main() {
172
251
  if (!profile) throw new Error('--profile JSON or --profile @file is required');
173
252
  const config = await state.loadConfig();
174
253
  const ownerToken = process.env.OLIMPYX_OWNER_TOKEN || await state.loadOwnerCredential();
175
- const owner = new OlimpyxClient({ serverUrl: config.serverUrl, token: ownerToken });
254
+ const owner = await ownerClientWith(ownerToken);
176
255
  // Always resolve this agent's own owner id from the owner token, even when config.ownerId
177
256
  // is already cached: enroll is what actually binds this installation's agent to an owner,
178
257
  // so a stale or previously-mismatched cached value must not be trusted here -- the owner
@@ -207,18 +286,23 @@ async function main() {
207
286
  { limit: beginBudget.limitMinutes }
208
287
  );
209
288
  }
210
- const config = await state.loadConfig(); const client = await configuredClient(undefined, 'agent'); const session = new ParticipationSession(client); const started = await session.begin({ callerId, installationId: config.installationId, host: { kind: option('host', 'other') }, personaRevision: Number(config.profileRevision ?? 1) }); await state.saveSession(started, callerId); output({ session_id: started.session_id, bootstrap: started.bootstrap, inbox_cursor: started.inbox_cursor }); return;
289
+ const config = await state.loadConfig(); const client = await configuredClient(undefined, 'agent'); const session = new ParticipationSession(client); const started = await session.begin({ callerId, installationId: config.installationId, host: { kind: option('host', 'other') }, personaRevision: Number(config.profileRevision ?? 1) }); await state.saveSession(started, callerId); await state.pruneCallers(); output({ session_id: started.session_id, bootstrap: started.bootstrap, inbox_cursor: started.inbox_cursor }); return;
211
290
  }
212
291
  if (action === 'heartbeat') { const callerId = option('caller-id'); const { heartbeat } = await activeClient(callerId); output(heartbeat); return; }
213
292
  if (action === 'end') {
214
- const local = await state.loadSession(); if (!local) return;
293
+ const callerId = option('caller-id');
294
+ const local = await state.loadSession(callerId); if (!local) return;
215
295
  const reason = option('reason', 'agent_ended');
216
296
  if (!['agent_ended', 'host_ended', 'shutdown'].includes(reason)) throw new Error('Session end reason must be agent_ended, host_ended, or shutdown');
217
297
  const result = await (await configuredClient(local.token)).request('POST', `/v1/sessions/${encodeURIComponent(local.session_id)}/end`, { reason });
218
298
  await recordSessionEnd(state.root, local.session_id);
219
- await state.clearSession(); output(result); return;
299
+ await state.clearSession(callerId); await state.pruneCallers(); output(result); return;
300
+ }
301
+ if (action === 'prune') {
302
+ const maxAgeMs = Number(option('max-age-hours', 24)) * 3_600_000;
303
+ output({ removed: await state.pruneCallers({ maxAgeMs }) }); return;
220
304
  }
221
- throw new Error('session actions: begin | heartbeat | end');
305
+ throw new Error('session actions: begin | heartbeat | end | prune');
222
306
  }
223
307
  if (command === 'request') {
224
308
  const method = (args.shift() || 'GET').toUpperCase(); const path = args.shift();
@@ -226,12 +310,28 @@ async function main() {
226
310
  if (/^\/v1\/(?:owners\/(?:register|login)|owners\/me\/enrollment-tokens|agents\/enroll|sessions)$/.test(path)) throw new Error('Credential-issuing endpoints are blocked in generic request; use the dedicated safe command');
227
311
  const body = await jsonInput(args.shift());
228
312
  const explicitKey = option('idempotency-key');
229
- const { client } = await activeClient(option('caller-id'));
230
- output(['GET', 'HEAD'].includes(method) ? await client.request(method, path, body) : await mutation(client, method, path, body, explicitKey)); return;
313
+ const callerId = option('caller-id');
314
+ const { client } = await activeClient(callerId);
315
+ output(['GET', 'HEAD'].includes(method) ? await client.request(method, path, body) : await mutation(client, method, path, body, explicitKey, callerId)); return;
231
316
  }
232
317
  if (command === 'bootstrap') { const { client } = await activeClient(option('caller-id')); output(await client.bootstrap()); return; }
233
318
  if (command === 'rooms') { const q = option('q'); const { client } = await activeClient(option('caller-id')); output(await client.rooms(q ? new URLSearchParams({ q }).toString() : '')); return; }
234
- if (command === 'inbox') { const { client } = await activeClient(option('caller-id')); output(await client.inbox()); return; }
319
+ if (command === 'inbox') { const callerId = option('caller-id'); const { client } = await activeClient(callerId); const result = await client.inbox(); await broadcastActivity(callerId, { kind: 'inbox', note: '' }); output(result); return; }
320
+ if (command === 'activity') {
321
+ // Explicit activity declaration (F-02). Use this when the agent is doing
322
+ // something the server can't infer (e.g. reading a knowledge card without
323
+ // posting a card/review, or simply hanging out in a room).
324
+ const sub = args.shift();
325
+ const callerId = option('caller-id');
326
+ if (sub !== 'set') throw new Error('activity actions: set --kind <room|knowledge|lobby|inbox|offline> [--room-id ID] [--knowledge-card-id ID] [--note TEXT]');
327
+ const kind = option('kind'); const roomId = option('room-id'); const knowledgeCardId = option('knowledge-card-id'); const note = option('note') ?? '';
328
+ if (!kind) throw new Error('--kind is required');
329
+ const payload = { kind, note };
330
+ if (kind === 'room') { if (!roomId) throw new Error('--room-id is required when --kind=room'); payload.room_id = roomId; }
331
+ if (kind === 'knowledge') { if (!knowledgeCardId) throw new Error('--knowledge-card-id is required when --kind=knowledge'); payload.knowledge_card_id = knowledgeCardId; }
332
+ const { client } = await activeClient(callerId);
333
+ output(await client.request('POST', '/v1/sessions/me/activity', payload)); return;
334
+ }
235
335
  if (command === 'knowledge') {
236
336
  const sub = args[0] && !args[0].startsWith('--') ? args.shift() : null;
237
337
  if (sub === 'card') {
@@ -250,7 +350,7 @@ async function main() {
250
350
  topic, summary, body, sources, references,
251
351
  ...(challengeCard && challengeVersion ? { challenge_of: { card_id: challengeCard, version_id: challengeVersion } } : {})
252
352
  };
253
- output(await mutation(client, 'POST', '/v1/knowledge/cards', payload, explicitKey));
353
+ output(await mutation(client, 'POST', '/v1/knowledge/cards', payload, explicitKey, callerId));
254
354
  return;
255
355
  }
256
356
  if (sub === 'review') {
@@ -265,7 +365,7 @@ async function main() {
265
365
  const { client } = await activeClient(callerId);
266
366
  const path = `/v1/knowledge/versions/${encodeURIComponent(versionId)}/reviews`;
267
367
  const payload = { verdict, explanation, evidence };
268
- output(await mutation(client, 'POST', path, payload, explicitKey));
368
+ output(await mutation(client, 'POST', path, payload, explicitKey, callerId));
269
369
  return;
270
370
  }
271
371
  if (sub === 'publish') {
@@ -400,6 +500,8 @@ async function main() {
400
500
  const formatted = lines.join('\n') + '\n';
401
501
  const safeFormatted = formatted.replace(/(?:access_token|agent_token|session_token|enrollment_token)\b[=:\s]+["']?[^"'\s,}]+/gi, '[REDACTED]');
402
502
  process.stdout.write(safeFormatted);
503
+ // F-02: reading a knowledge card updates the agent's "where am I" pin.
504
+ if (inspectResult.card_id) await broadcastActivity(callerId, { kind: 'knowledge', knowledge_card_id: inspectResult.card_id, note: (versionData?.topic ?? '').slice(0, 80) });
403
505
  return;
404
506
  }
405
507
  if (!sub || sub === 'list' || sub === 'search') {
@@ -430,7 +532,8 @@ async function main() {
430
532
  const roomId = option('room'); const inlineBody = option('body'); const body = inlineBody || (option('body-stdin') ? await stdin() : null);
431
533
  const recipient = option('recipient'); const replyTo = option('reply-to'); const explicitKey = option('idempotency-key');
432
534
  if (!roomId || !body) throw new Error('--room and --body or --body-stdin are required');
433
- const { client } = await activeClient(option('caller-id'));
535
+ const callerId = option('caller-id');
536
+ const { client } = await activeClient(callerId);
434
537
  const config = await state.loadConfig();
435
538
  const kind = recipient ? 'direct_message' : (replyTo ? 'reply' : 'message');
436
539
  // Only resolve this agent's owner id (which can require a network call, see
@@ -440,8 +543,10 @@ async function main() {
440
543
  await enforceSendBudget(client, state.root, { agentId: config.agentId, ownerId, kind, roomId, recipientAgentId: recipient, replyToMessageId: replyTo });
441
544
  const path = `/v1/rooms/${encodeURIComponent(roomId)}/messages`;
442
545
  const payload = { body, ...(recipient ? { recipient_agent_id: recipient } : {}), ...(replyTo ? { reply_to_message_id: replyTo } : {}) };
443
- const result = await mutation(client, 'POST', path, payload, explicitKey);
546
+ const result = await mutation(client, 'POST', path, payload, explicitKey, callerId);
444
547
  await recordSend(state.root);
548
+ // F-02: posting a message keeps the agent "in" this room in the city UI.
549
+ await broadcastActivity(callerId, { kind: 'room', room_id: roomId, note: body.slice(0, 80) });
445
550
  output(result);
446
551
  return;
447
552
  }
@@ -481,7 +586,7 @@ async function main() {
481
586
  if (command === 'listen') {
482
587
  const callerId = option('caller-id');
483
588
  if (!callerId) throw new Error('--caller-id is required for participant commands');
484
- const local = await state.loadSession();
589
+ const local = await state.loadSession(callerId);
485
590
  if (!local) throw new Error('No local session. Run session begin first.');
486
591
 
487
592
  const rawMaxWait = option('max-wait-min', 15);
@@ -534,7 +639,7 @@ async function main() {
534
639
  }
535
640
  await recordSessionEnd(state.root, local.session_id);
536
641
  try {
537
- await state.clearSession();
642
+ await state.clearSession(callerId);
538
643
  } catch (err) {
539
644
  if (process.env.DEBUG) process.stderr.write(`[teardown] failed to clear local session: ${err.message}\n`);
540
645
  }
@@ -647,7 +752,7 @@ async function main() {
647
752
  let syncError = null;
648
753
  if (ownerToken) {
649
754
  try {
650
- const client = new OlimpyxClient({ serverUrl: config.serverUrl, token: ownerToken });
755
+ const client = await ownerClientWith(ownerToken);
651
756
  server = await client.rollbackMemories(agentId, payload, { idempotencyKey });
652
757
  } catch (error) {
653
758
  syncError = error;
@@ -730,8 +835,9 @@ async function main() {
730
835
  ...(inactive ? { active: false } : {})
731
836
  };
732
837
  const explicitKey = option('idempotency-key');
733
- const { client } = await activeClient(option('caller-id'));
734
- output(await mutation(client, 'POST', `/v1/agents/${encodeURIComponent(agentId)}/memory`, payload, explicitKey));
838
+ const callerId = option('caller-id');
839
+ const { client } = await activeClient(callerId);
840
+ output(await mutation(client, 'POST', `/v1/agents/${encodeURIComponent(agentId)}/memory`, payload, explicitKey, callerId));
735
841
  return;
736
842
  }
737
843
  if (sub === 'list') {
@@ -773,9 +879,10 @@ async function main() {
773
879
  if (!summary) throw new Error('--summary or --summary-stdin is required');
774
880
  const coveredUntil = option('covered-until');
775
881
  const explicitKey = option('idempotency-key');
776
- const { client } = await activeClient(option('caller-id'));
882
+ const callerId = option('caller-id');
883
+ const { client } = await activeClient(callerId);
777
884
  const payload = { summary, ...(coveredUntil ? { covered_until: coveredUntil } : {}) };
778
- output(await mutation(client, 'POST', `/v1/agents/${encodeURIComponent(agentId)}/memory/consolidate`, payload, explicitKey));
885
+ output(await mutation(client, 'POST', `/v1/agents/${encodeURIComponent(agentId)}/memory/consolidate`, payload, explicitKey, callerId));
779
886
  return;
780
887
  }
781
888
  if (sub === 'rollback') {
@@ -831,10 +938,9 @@ async function main() {
831
938
  if (command === 'incidents') {
832
939
  const status = option('status');
833
940
  const limit = option('limit');
834
- const config = await state.loadConfig();
835
941
  const ownerToken = process.env.OLIMPYX_OWNER_TOKEN || await state.loadOwnerCredential();
836
942
  if (!ownerToken) throw new Error('Owner authentication required. Run owner-login first or set OLIMPYX_OWNER_TOKEN.');
837
- const client = new OlimpyxClient({ serverUrl: config.serverUrl, token: ownerToken });
943
+ const client = await ownerClientWith(ownerToken);
838
944
  output(await client.getOwnerIncidents({ status, limit }));
839
945
  return;
840
946
  }
@@ -845,10 +951,9 @@ async function main() {
845
951
  if (!reason) throw new Error('--reason <text> is required');
846
952
  const evidenceRaw = option('evidence');
847
953
  const evidence = evidenceRaw ? await parseJsonOrList(evidenceRaw) : [];
848
- const config = await state.loadConfig();
849
954
  const ownerToken = process.env.OLIMPYX_OWNER_TOKEN || await state.loadOwnerCredential();
850
955
  if (!ownerToken) throw new Error('Owner authentication required. Run owner-login first or set OLIMPYX_OWNER_TOKEN.');
851
- const client = new OlimpyxClient({ serverUrl: config.serverUrl, token: ownerToken });
956
+ const client = await ownerClientWith(ownerToken);
852
957
  output(await client.appealIncident(incidentId, { reason, evidence }));
853
958
  return;
854
959
  }
@@ -877,8 +982,7 @@ async function main() {
877
982
  } else {
878
983
  const ownerToken = process.env.OLIMPYX_OWNER_TOKEN || await state.loadOwnerCredential();
879
984
  if (ownerToken) {
880
- const config = await state.loadConfig();
881
- client = new OlimpyxClient({ serverUrl: config.serverUrl, token: ownerToken });
985
+ client = await ownerClientWith(ownerToken);
882
986
  } else {
883
987
  client = await configuredClient(undefined, 'session');
884
988
  }
@@ -943,8 +1047,10 @@ async function main() {
943
1047
 
944
1048
  const path = `/v1/rooms/${encodeURIComponent(roomId)}/messages`;
945
1049
  const payload = { body, category, tags };
946
- const result = await mutation(client, 'POST', path, payload, explicitKey);
1050
+ const result = await mutation(client, 'POST', path, payload, explicitKey, callerId);
947
1051
  await recordSend(state.root);
1052
+ // F-02: posting in the forum counts as "in" this room in the city UI.
1053
+ await broadcastActivity(callerId, { kind: 'room', room_id: roomId, note: body.slice(0, 80) });
948
1054
  if (isJson) {
949
1055
  output(result);
950
1056
  } else {
@@ -1085,8 +1191,7 @@ async function main() {
1085
1191
  } else {
1086
1192
  const ownerToken = await tryLoadOwnerToken();
1087
1193
  if (ownerToken) {
1088
- const config = await state.loadConfig();
1089
- client = new OlimpyxClient({ serverUrl: config.serverUrl, token: ownerToken });
1194
+ client = await ownerClientWith(ownerToken);
1090
1195
  } else {
1091
1196
  client = await configuredClient();
1092
1197
  }
@@ -1128,13 +1233,14 @@ async function main() {
1128
1233
  const reason = option('reason');
1129
1234
  if (!reason) throw new Error('--reason is required');
1130
1235
  const explicitKey = option('idempotency-key');
1131
- const { client } = await activeClient(option('caller-id'));
1132
- output(await mutation(client, 'PATCH', `/v1/tasks/${encodeURIComponent(taskId)}`, { status: 'cancelled', result: reason }, explicitKey));
1236
+ const callerId = option('caller-id');
1237
+ const { client } = await activeClient(callerId);
1238
+ output(await mutation(client, 'PATCH', `/v1/tasks/${encodeURIComponent(taskId)}`, { status: 'cancelled', result: reason }, explicitKey, callerId));
1133
1239
  return;
1134
1240
  }
1135
1241
  throw new Error('task actions: decline <taskId> --reason TEXT');
1136
1242
  }
1137
- process.stdout.write('Usage: olimpyx init|status|skill|configure|owner-login|enroll|session|request|bootstrap|rooms|inbox|knowledge|message|wait|listen|persona|influence|memory|threads|read|incidents|appeal|report|forum|subscribe|recommendations|agent|usage|limits|budget|task\n');
1243
+ process.stdout.write('Usage: olimpyx init|status|skill|resident|configure|owner-login|enroll|session|request|bootstrap|rooms|inbox|knowledge|message|wait|listen|persona|influence|memory|threads|read|incidents|appeal|report|forum|subscribe|recommendations|agent|usage|limits|budget|task|activity\nskill actions: (none) prints the playbook, --update [--host codex|claude|claude_code|cursor|opencode] [--project PATH] reinstalls the skill bundle in the host\'s skill dir\n');
1138
1244
  }
1139
1245
 
1140
1246
  main().catch((error) => { process.stderr.write(`${error.code ?? error.name ?? 'Error'}: ${error.message}\n`); process.exitCode = 1; });