ticlawk 0.1.15-dev.6 → 0.1.16-dev.1

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/core/http.mjs CHANGED
@@ -1,4 +1,13 @@
1
1
  import { createServer } from 'node:http';
2
+ import {
3
+ handleGroupMembers,
4
+ handleMessageRead,
5
+ handleMessageSend,
6
+ handleServerInfo,
7
+ handleTaskClaim,
8
+ handleTaskList,
9
+ handleTaskUpdate,
10
+ } from './agent-cli-handlers.mjs';
2
11
 
3
12
  function writeJson(res, statusCode, body) {
4
13
  res.writeHead(statusCode, { 'Content-Type': 'application/json' });
@@ -13,36 +22,95 @@ async function readBody(req) {
13
22
  return body;
14
23
  }
15
24
 
16
- export function startLocalHttpServer({ port, adapter }) {
25
+ async function readJsonBody(req) {
26
+ const raw = await readBody(req);
27
+ if (!raw) return {};
28
+ try {
29
+ return JSON.parse(raw);
30
+ } catch {
31
+ return null;
32
+ }
33
+ }
34
+
35
+ function parseQuery(url) {
36
+ const out = {};
37
+ const idx = url.indexOf('?');
38
+ if (idx < 0) return out;
39
+ const qs = new URLSearchParams(url.slice(idx + 1));
40
+ for (const [k, v] of qs.entries()) out[k] = v;
41
+ return out;
42
+ }
43
+
44
+ export function startLocalHttpServer({ port, adapter, ctx }) {
45
+ // The agent-cli handlers need a tiny binding-lookup surface to
46
+ // validate TICLAWK_RUNTIME_AGENT_ID against locally bound agents.
47
+ // We accept the same ctx the adapter was constructed with.
48
+ const cliCtx = ctx || { listBindings: () => [] };
49
+
17
50
  const server = createServer(async (req, res) => {
18
51
  res.setHeader('Access-Control-Allow-Origin', '*');
19
52
  res.setHeader('Access-Control-Allow-Methods', 'POST, GET, OPTIONS');
20
- res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
53
+ res.setHeader('Access-Control-Allow-Headers', 'Content-Type, X-Ticlawk-Acting-Agent-Id, X-Ticlawk-Runtime-Host-Id, X-Ticlawk-Runtime-Session-Id');
21
54
  if (req.method === 'OPTIONS') {
22
55
  res.writeHead(204);
23
56
  res.end();
24
57
  return;
25
58
  }
26
59
 
27
- if (req.method === 'GET' && req.url === '/health') {
28
- try {
60
+ const method = (req.method || 'GET').toUpperCase();
61
+ const urlNoQuery = (req.url || '').split('?')[0];
62
+
63
+ try {
64
+ if (method === 'GET' && urlNoQuery === '/health') {
29
65
  const health = await adapter.health();
30
- writeJson(res, 200, {
31
- ok: true,
32
- adapter: adapter.id,
33
- ...health,
34
- });
35
- } catch (err) {
36
- writeJson(res, 500, {
37
- ok: false,
38
- adapter: adapter.id,
39
- error: err?.message || 'health check failed',
40
- });
66
+ writeJson(res, 200, { ok: true, adapter: adapter.id, ...health });
67
+ return;
41
68
  }
42
- return;
43
- }
44
69
 
45
- writeJson(res, 404, { error: 'not found' });
70
+ // ── Agent CLI surface (called from runtime CLI tools) ──
71
+ if (urlNoQuery === '/agent/message/send' && method === 'POST') {
72
+ const body = await readJsonBody(req);
73
+ if (body === null) return writeJson(res, 400, { error: 'invalid json body' });
74
+ const r = await handleMessageSend(req, body, cliCtx);
75
+ return writeJson(res, r.status, r.body);
76
+ }
77
+ if (urlNoQuery === '/agent/message/read' && method === 'GET') {
78
+ const r = await handleMessageRead(req, parseQuery(req.url || ''), cliCtx);
79
+ return writeJson(res, r.status, r.body);
80
+ }
81
+ if (urlNoQuery === '/agent/task/claim' && method === 'POST') {
82
+ const body = await readJsonBody(req);
83
+ if (body === null) return writeJson(res, 400, { error: 'invalid json body' });
84
+ const r = await handleTaskClaim(req, body, cliCtx);
85
+ return writeJson(res, r.status, r.body);
86
+ }
87
+ if (urlNoQuery === '/agent/task/update' && method === 'POST') {
88
+ const body = await readJsonBody(req);
89
+ if (body === null) return writeJson(res, 400, { error: 'invalid json body' });
90
+ const r = await handleTaskUpdate(req, body, cliCtx);
91
+ return writeJson(res, r.status, r.body);
92
+ }
93
+ if (urlNoQuery === '/agent/task/list' && method === 'GET') {
94
+ const r = await handleTaskList(req, parseQuery(req.url || ''), cliCtx);
95
+ return writeJson(res, r.status, r.body);
96
+ }
97
+ if (urlNoQuery === '/agent/group/members' && method === 'GET') {
98
+ const r = await handleGroupMembers(req, parseQuery(req.url || ''), cliCtx);
99
+ return writeJson(res, r.status, r.body);
100
+ }
101
+ if (urlNoQuery === '/agent/server/info' && method === 'GET') {
102
+ const r = await handleServerInfo(req, parseQuery(req.url || ''), cliCtx);
103
+ return writeJson(res, r.status, r.body);
104
+ }
105
+
106
+ writeJson(res, 404, { error: 'not found' });
107
+ } catch (err) {
108
+ writeJson(res, 500, {
109
+ ok: false,
110
+ adapter: adapter.id,
111
+ error: err?.message || String(err),
112
+ });
113
+ }
46
114
  });
47
115
 
48
116
  server.on('error', (err) => {
@@ -59,6 +127,10 @@ export function startLocalHttpServer({ port, adapter }) {
59
127
  console.log(`[relay] HTTP server listening on :${port}`);
60
128
  console.log(`[relay] adapter: ${adapter.id}`);
61
129
  console.log('[relay] GET /health - daemon status check');
130
+ console.log('[relay] POST /agent/message/send - chat send (CLI -> daemon -> backend)');
131
+ console.log('[relay] GET /agent/message/read');
132
+ console.log('[relay] POST /agent/task/claim | update');
133
+ console.log('[relay] GET /agent/task/list | /agent/group/members | /agent/server/info');
62
134
  });
63
135
 
64
136
  return server;
@@ -1,9 +1,45 @@
1
+ /**
2
+ * Build the env passed to a spawned runtime (Codex/Claude Code/...).
3
+ *
4
+ * The blanket "strip everything TICLAWK_*" rule from earlier versions was
5
+ * too aggressive: it also wiped out the agent-context env we inject for
6
+ * the agent CLI to talk back to the local daemon. We now use a precise
7
+ * denylist of secret/config keys and explicitly allow the
8
+ * TICLAWK_RUNTIME_* and TICLAWK_API_URL keys through (the latter is
9
+ * read-only from the runtime's perspective — it never holds a credential
10
+ * on its own).
11
+ */
12
+
13
+ // Keys the daemon must never leak into child runtime processes.
14
+ // These hold credentials or operator config the agent shouldn't see.
15
+ const STRIPPED_KEYS = new Set([
16
+ 'TICLAWK_CONNECTOR_API_KEY',
17
+ 'TICLAWK_CONNECTOR_WS_URL',
18
+ 'TICLAWK_SETUP_CODE',
19
+ ]);
20
+
1
21
  export function buildRuntimeEnv(extra = {}) {
2
22
  const env = { ...process.env, ...extra };
3
- for (const key of Object.keys(env)) {
4
- if (key.startsWith('TICLAWK_')) {
5
- delete env[key];
6
- }
7
- }
23
+ for (const key of STRIPPED_KEYS) delete env[key];
8
24
  return env;
9
25
  }
26
+
27
+ /**
28
+ * Build the per-agent runtime env block. The daemon includes the
29
+ * resulting fields in `buildRuntimeEnv({ ...buildAgentRuntimeEnv(...) })`
30
+ * when spawning a runtime, so the agent CLI can talk back to the local
31
+ * daemon with a validated identity.
32
+ */
33
+ export function buildAgentRuntimeEnv({
34
+ agentId,
35
+ sessionId,
36
+ hostId,
37
+ daemonUrl,
38
+ } = {}) {
39
+ const out = {};
40
+ if (agentId) out.TICLAWK_RUNTIME_AGENT_ID = String(agentId);
41
+ if (sessionId) out.TICLAWK_RUNTIME_SESSION_ID = String(sessionId);
42
+ if (hostId) out.TICLAWK_RUNTIME_HOST_ID = String(hostId);
43
+ out.TICLAWK_RUNTIME_DAEMON_URL = daemonUrl || 'http://127.0.0.1:8741';
44
+ return out;
45
+ }
@@ -45,7 +45,21 @@ export async function sendAdapterMessage(adapter, binding, payload) {
45
45
  await adapter.send(binding, payload);
46
46
  }
47
47
 
48
- export async function sendResult(adapter, binding, inbound, result) {
48
+ /**
49
+ * Record the runtime's final turn output as activity (NOT chat).
50
+ *
51
+ * Previously called `sendResult` and treated as "the chat reply path".
52
+ * After the group-chat upgrade, chat is produced exclusively by the
53
+ * agent invoking `ticlawk message send` via the CLI. The runtime's
54
+ * raw final output is still surfaced for trajectory/debug UI, but it
55
+ * no longer materializes as a `messages` row — the trigger
56
+ * `project_agent_event` was updated in PR-2b to drop the chat
57
+ * projection.
58
+ *
59
+ * Renamed so the call sites read self-evidently: "record activity"
60
+ * never reads as "send a chat message".
61
+ */
62
+ export async function recordActivity(adapter, binding, inbound, result) {
49
63
  if (!result?.text && (!result?.mediaUrls || result.mediaUrls.length === 0)) return;
50
64
  await sendAdapterMessage(adapter, binding, {
51
65
  type: 'assistant',
@@ -56,6 +70,7 @@ export async function sendResult(adapter, binding, inbound, result) {
56
70
  });
57
71
  }
58
72
 
73
+
59
74
  export async function reportSubprocessFailure({ adapter, binding, inbound, runtimeName, info }) {
60
75
  if (!info || info.ok) return;
61
76
  const seconds = ((info.durationMs || 0) / 1000).toFixed(1);
@@ -1,5 +1,4 @@
1
1
  import { Bus } from './bus.mjs';
2
- import { getConfiguredAdapter } from './config.mjs';
3
2
  import { createAdapter } from './adapter-registry.mjs';
4
3
  import { getBinding, listBindings, upsertBinding, deleteBinding, findBindingByTarget } from './bindings/store.mjs';
5
4
  import { buildRuntimeContext, normalizeServiceType } from './runtime-registry.mjs';
@@ -43,7 +42,9 @@ function createCacheBinding(runtimes, getAdapter) {
43
42
  };
44
43
  }
45
44
 
46
- export async function createTiclawkController(adapterId = getConfiguredAdapter()) {
45
+ const ADAPTER_ID = 'ticlawk';
46
+
47
+ export async function createTiclawkController(adapterId = ADAPTER_ID) {
47
48
  const { runtimes } = await buildRuntimeContext();
48
49
  const resolveRuntimeBinding = createResolveRuntimeBinding(runtimes);
49
50
  let adapter;
@@ -74,7 +75,7 @@ export async function createTiclawkController(adapterId = getConfiguredAdapter()
74
75
  }
75
76
 
76
77
  export async function runTiclawkConnect(payload) {
77
- const adapterId = payload?.adapter || getConfiguredAdapter();
78
+ const adapterId = payload?.adapter || ADAPTER_ID;
78
79
  const { adapter } = await createTiclawkController(adapterId);
79
80
  if (typeof adapter.connect !== 'function') {
80
81
  return {
@@ -0,0 +1,89 @@
1
+ /**
2
+ * Standing prompt injected into every runtime turn after the group-chat
3
+ * upgrade.
4
+ *
5
+ * Tells the agent that:
6
+ * - chat is a side effect of `ticlawk message send`, not of its
7
+ * ordinary assistant output
8
+ * - incoming messages carry an envelope header to be replied to
9
+ * verbatim as `target`
10
+ * - groups need explicit @mention/assignment to be reply-worthy
11
+ * - substantial work must be claimed first via `ticlawk task claim`
12
+ *
13
+ * Structurally the rules copy Slock's standing prompt (照抄). Scope/
14
+ * examples are generalized to Ticlawk's broader use cases (code,
15
+ * research, schedule, document processing, etc.).
16
+ */
17
+
18
+ const STANDING_PROMPT = `You are an agent in Ticlawk. You communicate with people and other agents
19
+ only through the Ticlawk CLI installed at \`ticlawk\`. Your normal
20
+ assistant output is private activity text — it is NOT sent to users or
21
+ groups.
22
+
23
+ To send a chat message, run:
24
+ ticlawk message send --target "<target>" <<'EOF'
25
+ <your message>
26
+ EOF
27
+
28
+ Targets:
29
+ dm:@<user> private message
30
+ #<group> group conversation
31
+ #<group>:<msgid> thread under a top-level message in that group
32
+
33
+ Incoming messages arrive in this exact envelope:
34
+ [target=<target> msg=<id> seq=<n> time=<iso> type=<human|agent|system>] @<sender>: <text>
35
+
36
+ The \`target\` field tells you where the message came from. Reply to the
37
+ same target unless explicitly asked to move.
38
+
39
+ == When to reply ==
40
+ - DM to you: always respond.
41
+ - Group, you are @mentioned or directly assigned a task: respond.
42
+ - Group, no mention, conversation between others: stay silent. Do not
43
+ narrate.
44
+ - Only the person doing the work should report on it. If another agent
45
+ completed something, do not echo or summarize it.
46
+ - type=agent: do not reply unless the other agent explicitly addresses
47
+ you.
48
+ - type=system: read for context, do not reply unless it assigns work to
49
+ you.
50
+
51
+ == Claiming work ==
52
+ For substantive work (running tools, editing files, writing reports,
53
+ producing artifacts, doing research, scheduling, etc.), first claim the
54
+ originating message:
55
+ ticlawk task claim --message-id <id>
56
+ If the claim fails, someone else owns it — stop and do not duplicate
57
+ work.
58
+
59
+ == Workspace ==
60
+ Your cwd is your persistent, agent-owned workspace. When operating on a
61
+ specific project (a repo, a dataset, a document tree, a research
62
+ notebook), first choose the target directory inside or outside this
63
+ workspace, then run your commands there. Do not assume the chat target
64
+ equals a workdir.
65
+
66
+ == Other tools ==
67
+ ticlawk message read --target <target> [--around <msgid>] [--limit N]
68
+ ticlawk group members --target <target>
69
+ ticlawk server info
70
+ ticlawk task list [--target <target>]
71
+ ticlawk task update --task-id <id> --status <open|claimed|done|canceled>
72
+
73
+ == Reply etiquette ==
74
+ - Skip idle narration. Only send messages when you have actionable
75
+ content (a question that unblocks you, a status that the user needs to
76
+ know, or the final answer/artifact you were asked for).
77
+ - Quote the exact target you received when replying so the message
78
+ lands in the same place.
79
+ - For agent-to-agent coordination, prefer task assignment and thread
80
+ replies over top-level group chat.
81
+ `;
82
+
83
+ export function buildStandingPrompt(_ctx = {}) {
84
+ // The current prompt is identity-free. Hook signature reserved so we
85
+ // can later compose in agent handle / known conversation list / etc.
86
+ return STANDING_PROMPT;
87
+ }
88
+
89
+ export { STANDING_PROMPT };
@@ -9,6 +9,8 @@
9
9
 
10
10
  import { existsSync } from 'node:fs';
11
11
  import { basename } from 'node:path';
12
+ import { buildAgentRuntimeEnv } from '../../core/runtime-env.mjs';
13
+ import { buildStandingPrompt } from '../_shared/standing-prompt.mjs';
12
14
  import {
13
15
  createCCSession,
14
16
  getClaudeCodeRuntimeHealth,
@@ -27,7 +29,7 @@ import { emitWorkerEvent } from '../../core/events/worker-events.mjs';
27
29
  import {
28
30
  shouldStreamRuntime,
29
31
  sendAdapterMessage,
30
- sendResult,
32
+ recordActivity,
31
33
  reportSubprocessFailure,
32
34
  terminalRuntimeFailure,
33
35
  updateBindingRuntimeMeta,
@@ -45,16 +47,26 @@ export const claudeCodeRuntime = {
45
47
 
46
48
  // Run a Claude turn and wait for the final result on stdout. This is
47
49
  // the worker-first path used by the adapter for direct reply delivery.
48
- runTurn({ sessionId, projectDir, claudePath }, text, opts = {}) {
49
- return runCCPrompt({ sessionId, projectDir, message: text, claudePath, timeoutMs: opts.timeoutMs });
50
+ runTurn({ sessionId, projectDir, claudePath, agentEnv }, text, opts = {}) {
51
+ return runCCPrompt({
52
+ sessionId,
53
+ projectDir,
54
+ message: text,
55
+ claudePath,
56
+ agentEnv,
57
+ appendSystemPrompt: opts.appendSystemPrompt || null,
58
+ timeoutMs: opts.timeoutMs,
59
+ });
50
60
  },
51
61
 
52
- runTurnStream({ sessionId, projectDir, claudePath }, text, opts = {}) {
62
+ runTurnStream({ sessionId, projectDir, claudePath, agentEnv }, text, opts = {}) {
53
63
  return streamCCPrompt({
54
64
  sessionId,
55
65
  projectDir,
56
66
  message: text,
57
67
  claudePath,
68
+ agentEnv,
69
+ appendSystemPrompt: opts.appendSystemPrompt || null,
58
70
  timeoutMs: opts.timeoutMs,
59
71
  onEvent: opts.onEvent,
60
72
  });
@@ -232,8 +244,15 @@ export const claudeCodeRuntime = {
232
244
  try {
233
245
  const claudePath = requireClaudePath(runtimeClaudePath);
234
246
  const claudeVersion = getClaudeCodeRuntimeHealth(claudePath).version || meta.claudeVersion || null;
247
+ const agentEnv = buildAgentRuntimeEnv({
248
+ agentId: binding.id,
249
+ sessionId,
250
+ hostId: binding.runtime_host_id,
251
+ });
252
+ const appendSystemPrompt = buildStandingPrompt({ agentId: binding.id });
235
253
  const result = shouldStreamRuntime(this.name, this)
236
- ? await this.runTurnStream({ sessionId, projectDir, claudePath }, message, {
254
+ ? await this.runTurnStream({ sessionId, projectDir, claudePath, agentEnv }, message, {
255
+ appendSystemPrompt,
237
256
  onEvent: async (event) => {
238
257
  if (event?.type === 'turn.started') {
239
258
  await emitWorkerEvent({
@@ -267,14 +286,14 @@ export const claudeCodeRuntime = {
267
286
  }
268
287
  },
269
288
  })
270
- : await this.runTurn({ sessionId, projectDir, claudePath }, message);
289
+ : await this.runTurn({ sessionId, projectDir, claudePath, agentEnv }, message, { appendSystemPrompt });
271
290
  const nextBinding = await updateBindingRuntimeMeta(ctx, binding, {
272
291
  sessionId: result?.sessionId || meta.sessionId,
273
292
  runtimePath: claudePath,
274
293
  claudePath,
275
294
  claudeVersion,
276
295
  }, { status: 'connected' });
277
- await sendResult(adapter, nextBinding, inbound, {
296
+ await recordActivity(adapter, nextBinding, inbound, {
278
297
  ...result,
279
298
  media: normalizeOutboundMedia(result),
280
299
  });
@@ -106,15 +106,18 @@ function extractCCAssistantText(payload) {
106
106
  .join('');
107
107
  }
108
108
 
109
- export function runCCPrompt({ sessionId, projectDir, message, claudePath = null, timeoutMs = Number(process.env.CC_EXEC_TIMEOUT_MS || DEFAULT_CC_EXEC_TIMEOUT_MS) }) {
109
+ export function runCCPrompt({ sessionId, projectDir, message, claudePath = null, agentEnv = null, appendSystemPrompt = null, timeoutMs = Number(process.env.CC_EXEC_TIMEOUT_MS || DEFAULT_CC_EXEC_TIMEOUT_MS) }) {
110
+ const systemArgs = appendSystemPrompt
111
+ ? ['--append-system-prompt', appendSystemPrompt]
112
+ : [];
110
113
  const args = sessionId
111
- ? ['-p', message, '--resume', sessionId, '--dangerously-skip-permissions', '--output-format', 'json']
112
- : ['-p', message, '--dangerously-skip-permissions', '--output-format', 'json'];
114
+ ? ['-p', message, '--resume', sessionId, '--dangerously-skip-permissions', '--output-format', 'json', ...systemArgs]
115
+ : ['-p', message, '--dangerously-skip-permissions', '--output-format', 'json', ...systemArgs];
113
116
 
114
117
  return new Promise((resolve, reject) => {
115
118
  const startedAt = Date.now();
116
119
  const claudeCommand = requireClaudePath(claudePath);
117
- const child = spawn(claudeCommand, args, { cwd: projectDir, env: buildRuntimeEnv(), stdio: ['ignore', 'pipe', 'ignore'] });
120
+ const child = spawn(claudeCommand, args, { cwd: projectDir, env: buildRuntimeEnv(agentEnv || {}), stdio: ['ignore', 'pipe', 'ignore'] });
118
121
  let stdout = '';
119
122
  let settled = false;
120
123
 
@@ -206,17 +209,22 @@ export function streamCCPrompt({
206
209
  projectDir,
207
210
  message,
208
211
  claudePath = null,
212
+ agentEnv = null,
213
+ appendSystemPrompt = null,
209
214
  timeoutMs = Number(process.env.CC_EXEC_TIMEOUT_MS || DEFAULT_CC_EXEC_TIMEOUT_MS),
210
215
  onEvent,
211
216
  }) {
217
+ const systemArgs = appendSystemPrompt
218
+ ? ['--append-system-prompt', appendSystemPrompt]
219
+ : [];
212
220
  const args = sessionId
213
- ? ['-p', message, '--resume', sessionId, '--verbose', '--output-format', 'stream-json', '--include-partial-messages', '--dangerously-skip-permissions']
214
- : ['-p', message, '--verbose', '--output-format', 'stream-json', '--include-partial-messages', '--dangerously-skip-permissions'];
221
+ ? ['-p', message, '--resume', sessionId, '--verbose', '--output-format', 'stream-json', '--include-partial-messages', '--dangerously-skip-permissions', ...systemArgs]
222
+ : ['-p', message, '--verbose', '--output-format', 'stream-json', '--include-partial-messages', '--dangerously-skip-permissions', ...systemArgs];
215
223
 
216
224
  return new Promise((resolve, reject) => {
217
225
  const startedAt = Date.now();
218
226
  const claudeCommand = requireClaudePath(claudePath);
219
- const child = spawn(claudeCommand, args, { cwd: projectDir, env: buildRuntimeEnv(), stdio: ['ignore', 'pipe', 'ignore'] });
227
+ const child = spawn(claudeCommand, args, { cwd: projectDir, env: buildRuntimeEnv(agentEnv || {}), stdio: ['ignore', 'pipe', 'ignore'] });
220
228
  let stdout = '';
221
229
  let buffer = '';
222
230
  let settled = false;
@@ -25,12 +25,14 @@ import {
25
25
  shouldStreamRuntime,
26
26
  createDeltaAggregator,
27
27
  sendAdapterMessage,
28
- sendResult,
28
+ recordActivity,
29
29
  reportSubprocessFailure,
30
30
  terminalRuntimeFailure,
31
31
  updateBindingRuntimeMeta,
32
32
  isRuntimeGatewayFailure,
33
33
  } from '../../core/runtime-support.mjs';
34
+ import { buildAgentRuntimeEnv } from '../../core/runtime-env.mjs';
35
+ import { buildStandingPrompt } from '../_shared/standing-prompt.mjs';
34
36
 
35
37
  export const codexRuntime = {
36
38
  name: 'codex',
@@ -48,23 +50,26 @@ export const codexRuntime = {
48
50
  };
49
51
  },
50
52
 
51
- runTurn({ sessionId, cwd, codexPath }, text, opts = {}) {
53
+ runTurn({ sessionId, cwd, codexPath, agentEnv }, text, opts = {}) {
52
54
  return runCodexPrompt({
53
55
  sessionId,
54
56
  cwd,
55
57
  message: text,
56
58
  codexPath,
59
+ agentEnv,
57
60
  input: opts.input,
58
61
  timeoutMs: opts.timeoutMs,
59
62
  });
60
63
  },
61
64
 
62
- runTurnStream({ sessionId, cwd, codexPath }, text, opts = {}) {
65
+ runTurnStream({ sessionId, cwd, codexPath, agentEnv }, text, opts = {}) {
63
66
  return streamCodexPrompt({
64
67
  sessionId,
65
68
  cwd,
66
69
  message: text,
67
70
  codexPath,
71
+ agentEnv,
72
+ developerInstructions: opts.developerInstructions || null,
68
73
  input: opts.input,
69
74
  timeoutMs: opts.timeoutMs,
70
75
  onEvent: opts.onEvent,
@@ -177,9 +182,16 @@ export const codexRuntime = {
177
182
  try {
178
183
  const runtimeCodexPath = requireCodexPath(meta.codexPath || meta.runtimePath);
179
184
  const runtimeCodexVersion = getCodexRuntimeHealth(runtimeCodexPath).version || meta.codexVersion || null;
185
+ const agentEnv = buildAgentRuntimeEnv({
186
+ agentId: binding.id,
187
+ sessionId: meta.sessionId,
188
+ hostId: binding.runtime_host_id,
189
+ });
190
+ const developerInstructions = buildStandingPrompt({ agentId: binding.id });
180
191
  const result = (shouldRotate || inbound.action === 'image' || shouldStreamRuntime(this.name, this))
181
- ? await this.runTurnStream({ sessionId: shouldRotate ? null : meta.sessionId, cwd: meta.cwd, codexPath: runtimeCodexPath }, message, {
192
+ ? await this.runTurnStream({ sessionId: shouldRotate ? null : meta.sessionId, cwd: meta.cwd, codexPath: runtimeCodexPath, agentEnv }, message, {
182
193
  input: codexInput,
194
+ developerInstructions,
183
195
  onEvent: async (event) => {
184
196
  if (event?.type === 'turn.started') {
185
197
  await emitWorkerEvent({
@@ -205,7 +217,7 @@ export const codexRuntime = {
205
217
  }
206
218
  },
207
219
  })
208
- : await this.runTurn({ sessionId: meta.sessionId, cwd: meta.cwd, codexPath: runtimeCodexPath }, message, {
220
+ : await this.runTurn({ sessionId: meta.sessionId, cwd: meta.cwd, codexPath: runtimeCodexPath, agentEnv }, message, {
209
221
  input: codexInput,
210
222
  });
211
223
 
@@ -220,7 +232,7 @@ export const codexRuntime = {
220
232
  rotatePending: false,
221
233
  lastRotatedAt: shouldRotate ? new Date().toISOString() : meta.lastRotatedAt,
222
234
  }, { status: 'connected' });
223
- await sendResult(adapter, nextBinding, inbound, {
235
+ await recordActivity(adapter, nextBinding, inbound, {
224
236
  ...result,
225
237
  media: normalizeOutboundMedia(result),
226
238
  });
@@ -221,13 +221,13 @@ function isSubAgentThread(thread) {
221
221
  return Boolean(source.subAgent || source.sub_agent);
222
222
  }
223
223
 
224
- export function runCodexPrompt({ sessionId, cwd, message, codexPath = null, timeoutMs = Number(process.env.CODEX_EXEC_TIMEOUT_MS || DEFAULT_CODEX_EXEC_TIMEOUT_MS) }) {
224
+ export function runCodexPrompt({ sessionId, cwd, message, codexPath = null, agentEnv = null, timeoutMs = Number(process.env.CODEX_EXEC_TIMEOUT_MS || DEFAULT_CODEX_EXEC_TIMEOUT_MS) }) {
225
225
  return new Promise((resolve, reject) => {
226
226
  const startedAt = Date.now();
227
227
  const codexCommand = requireCodexPath(codexPath);
228
228
  const child = spawn(codexCommand, buildCodexExecArgs({ sessionId, message }), {
229
229
  cwd,
230
- env: buildRuntimeEnv(),
230
+ env: buildRuntimeEnv(agentEnv || {}),
231
231
  stdio: ['ignore', 'pipe', 'ignore'],
232
232
  });
233
233
 
@@ -344,6 +344,8 @@ export function streamCodexPrompt({
344
344
  cwd,
345
345
  message,
346
346
  codexPath = null,
347
+ agentEnv = null,
348
+ developerInstructions = null,
347
349
  input = null,
348
350
  timeoutMs = Number(process.env.CODEX_EXEC_TIMEOUT_MS || DEFAULT_CODEX_EXEC_TIMEOUT_MS),
349
351
  onEvent,
@@ -353,7 +355,7 @@ export function streamCodexPrompt({
353
355
  const codexCommand = requireCodexPath(codexPath);
354
356
  const child = spawn(codexCommand, ['app-server'], {
355
357
  cwd,
356
- env: buildRuntimeEnv(),
358
+ env: buildRuntimeEnv(agentEnv || {}),
357
359
  stdio: ['pipe', 'pipe', 'ignore'],
358
360
  });
359
361
 
@@ -598,6 +600,7 @@ export function streamCodexPrompt({
598
600
  cwd,
599
601
  approvalPolicy: 'never',
600
602
  sandbox: 'danger-full-access',
603
+ ...(developerInstructions ? { developerInstructions } : {}),
601
604
  });
602
605
  } else {
603
606
  const started = await send('thread/start', {
@@ -605,6 +608,7 @@ export function streamCodexPrompt({
605
608
  model: process.env.CODEX_MODEL || null,
606
609
  approvalPolicy: 'never',
607
610
  sandbox: 'danger-full-access',
611
+ ...(developerInstructions ? { developerInstructions } : {}),
608
612
  });
609
613
  rootThreadId = started?.thread?.id || rootThreadId;
610
614
  threadPath = started?.thread?.path || threadPath;
@@ -636,7 +640,7 @@ export function streamCodexPrompt({
636
640
  });
637
641
  }
638
642
 
639
- export function createCodexSession({ cwd, message, codexPath = null, timeoutMs = Number(process.env.CODEX_EXEC_TIMEOUT_MS || DEFAULT_CODEX_EXEC_TIMEOUT_MS) }) {
643
+ export function createCodexSession({ cwd, message, codexPath = null, agentEnv = null, timeoutMs = Number(process.env.CODEX_EXEC_TIMEOUT_MS || DEFAULT_CODEX_EXEC_TIMEOUT_MS) }) {
640
644
  return new Promise((resolve, reject) => {
641
645
  const startedAt = Date.now();
642
646
  const codexCommand = requireCodexPath(codexPath);
@@ -651,7 +655,7 @@ export function createCodexSession({ cwd, message, codexPath = null, timeoutMs =
651
655
  ],
652
656
  {
653
657
  cwd,
654
- env: buildRuntimeEnv(),
658
+ env: buildRuntimeEnv(agentEnv || {}),
655
659
  stdio: ['ignore', 'pipe', 'ignore'],
656
660
  }
657
661
  );
@@ -1,8 +1,15 @@
1
1
  import { normalizeOutboundMedia } from '../../core/media/outbound.mjs';
2
- import { reportSubprocessFailure, sendAdapterMessage, sendResult, terminalRuntimeFailure } from '../../core/runtime-support.mjs';
2
+ import { reportSubprocessFailure, sendAdapterMessage, recordActivity, terminalRuntimeFailure } from '../../core/runtime-support.mjs';
3
+ import { buildStandingPrompt } from '../_shared/standing-prompt.mjs';
3
4
  import { GATEWAY_HOST, GATEWAY_PORT } from './identity.mjs';
4
5
  import { buildOpenClawSessionKey, normalizeOpenClawAgentId, resolveOpenClawWorkspace } from './target.mjs';
5
6
  import { askGateway, isGatewayReady, registerOpenClawChannel } from './gateway.mjs';
7
+
8
+ // Tracks which (agentId, sessionKey) pairs already saw the standing
9
+ // prompt this process lifetime. OpenClaw's gateway holds session state
10
+ // out of process, so we err on the side of "inject on first observed
11
+ // turn after daemon restart"; the gateway dedupes redundant context.
12
+ const standingPromptSeen = new Set();
6
13
  import { addInFlight, recoverInFlightEntries, removeInFlight } from './inflight.mjs';
7
14
  import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
8
15
  import { extname } from 'node:path';
@@ -125,12 +132,24 @@ export const openClawRuntime = {
125
132
  if (!binding) return false;
126
133
  const adapter = ctx.adapter;
127
134
  const meta = binding.runtimeMeta || {};
128
- const prompt = inbound.action === 'image'
135
+ const rawPrompt = inbound.action === 'image'
129
136
  ? await buildOpenClawImagePrompt(inbound)
130
137
  : (inbound.text || '').trim();
131
138
  const agentId = normalizeOpenClawAgentId(meta.agentId || binding.id);
132
139
  const sessionId = String(meta.sessionKey || buildOpenClawSessionKey(agentId)).trim();
133
140
 
141
+ // Inject the standing prompt on the first observed turn after a
142
+ // daemon start for this (agent, session) pair. OpenClaw has no
143
+ // separate "system prompt" parameter on the gateway, so we prepend
144
+ // exactly once.
145
+ const standingKey = `${agentId}|${sessionId}`;
146
+ let prompt = rawPrompt;
147
+ if (!standingPromptSeen.has(standingKey)) {
148
+ const standing = buildStandingPrompt({ agentId: binding.id });
149
+ prompt = `${standing}\n\n---\n\n${rawPrompt}`;
150
+ standingPromptSeen.add(standingKey);
151
+ }
152
+
134
153
  if (inbound.messageId) {
135
154
  addInFlight({
136
155
  messageId: inbound.messageId,
@@ -152,7 +171,7 @@ export const openClawRuntime = {
152
171
  });
153
172
  },
154
173
  });
155
- await sendResult(adapter, binding, inbound, {
174
+ await recordActivity(adapter, binding, inbound, {
156
175
  ...result,
157
176
  media: normalizeOutboundMedia(result),
158
177
  });