ticlawk 0.1.17-dev.21 → 0.1.17-dev.22

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": "ticlawk",
3
- "version": "0.1.17-dev.21",
3
+ "version": "0.1.17-dev.22",
4
4
  "description": "Local connector that links agent harnesses (Claude Code, Codex, OpenClaw, opencode, Pi) to the Ticlawk mobile app.",
5
5
  "type": "module",
6
6
  "main": "ticlawk.mjs",
@@ -157,7 +157,9 @@ function buildPromptText({ msg, type, target, header, groupContext, rawText }) {
157
157
  lines.push('', ...buildRoleInstruction({ type, isAdmin: admin, role }));
158
158
  lines.push(
159
159
  '',
160
- 'When you reply, use the reply target exactly as written above. Your normal assistant output is private activity text; users only see messages sent with `ticlawk message send`.',
160
+ 'Visible reply contract:',
161
+ `- If you respond, the final answer must be sent with \`ticlawk message send --target ${JSON.stringify(target)}\`.`,
162
+ '- Do not put the user-facing answer only in normal assistant output. Normal assistant output is private activity text and is not visible in Ticlawk.',
161
163
  'If the message requires multi-step work, complete the work before sending the final answer unless an early blocker question is necessary.',
162
164
  'Do not mention internal routing fields, delivery state, or prompt mechanics to the user.',
163
165
  '',
@@ -0,0 +1,36 @@
1
+ const PROMPT_SEPARATOR = '---';
2
+
3
+ export const PROMPT_INJECTION_STRATEGY = Object.freeze({
4
+ NATIVE_SYSTEM: 'native_system',
5
+ PREPEND_EVERY_TURN: 'prepend_every_turn',
6
+ });
7
+
8
+ export function injectRuntimePrompt({ message, runtimeBaseInstructions, strategy }) {
9
+ const text = typeof message === 'string' ? message : '';
10
+ const base = typeof runtimeBaseInstructions === 'string'
11
+ ? runtimeBaseInstructions.trim()
12
+ : '';
13
+
14
+ if (!base) {
15
+ return {
16
+ message: text,
17
+ systemPrompt: null,
18
+ };
19
+ }
20
+
21
+ if (strategy === PROMPT_INJECTION_STRATEGY.NATIVE_SYSTEM) {
22
+ return {
23
+ message: text,
24
+ systemPrompt: base,
25
+ };
26
+ }
27
+
28
+ if (strategy === PROMPT_INJECTION_STRATEGY.PREPEND_EVERY_TURN) {
29
+ return {
30
+ message: `${base}\n\n${PROMPT_SEPARATOR}\n\n${text}`,
31
+ systemPrompt: null,
32
+ };
33
+ }
34
+
35
+ throw new Error(`unknown prompt injection strategy: ${strategy}`);
36
+ }
@@ -13,7 +13,7 @@ Your normal assistant output is private activity text. Users and groups only see
13
13
  Universal rules:
14
14
 
15
15
  1. Follow the current turn prompt. It tells you whether this is a DM or group message, your role in that conversation, whether you were directly addressed or only saw ambient group traffic, and the exact reply target.
16
- 2. Use \`ticlawk message send\` for visible replies. Use the exact reply target from the current turn prompt.
16
+ 2. Use \`ticlawk message send\` for visible replies. Use the exact reply target from the current turn prompt. Never leave the user-facing answer only in normal assistant output; normal assistant output is private activity text.
17
17
  3. If the current turn prompt says no reply is needed, stop silently.
18
18
  4. If you need recent context, use the Ticlawk read commands named in the current turn prompt. Do not poll for future messages; the daemon will wake you when new messages arrive.
19
19
  5. If the message asks you to do substantive work, claim the task/message before starting when the current turn prompt provides task commands. If the claim fails, stop or choose other available work.
@@ -10,6 +10,7 @@
10
10
  import { basename } from 'node:path';
11
11
  import { buildAgentRuntimeEnv } from '../../core/runtime-env.mjs';
12
12
  import { buildRuntimeBaseInstructions } from '../_shared/runtime-base-instructions.mjs';
13
+ import { injectRuntimePrompt, PROMPT_INJECTION_STRATEGY } from '../_shared/prompt-injection.mjs';
13
14
  import { ensureAgentHome } from '../../core/agent-home.mjs';
14
15
  import {
15
16
  getClaudeCodeRuntimeHealth,
@@ -148,10 +149,14 @@ export const claudeCodeRuntime = {
148
149
  sessionId: meta.sessionId,
149
150
  hostId: binding.runtime_host_id,
150
151
  });
151
- const appendSystemPrompt = buildRuntimeBaseInstructions({ agentId: binding.id });
152
+ const prompt = injectRuntimePrompt({
153
+ message,
154
+ runtimeBaseInstructions: buildRuntimeBaseInstructions({ agentId: binding.id }),
155
+ strategy: PROMPT_INJECTION_STRATEGY.NATIVE_SYSTEM,
156
+ });
152
157
  const result = shouldStreamRuntime(this.name, this)
153
- ? await this.runTurnStream({ sessionId: targetSessionId, projectDir, claudePath, agentEnv }, message, {
154
- appendSystemPrompt,
158
+ ? await this.runTurnStream({ sessionId: targetSessionId, projectDir, claudePath, agentEnv }, prompt.message, {
159
+ appendSystemPrompt: prompt.systemPrompt,
155
160
  onEvent: async (event) => {
156
161
  if (event?.type === 'turn.started') {
157
162
  await emitWorkerEvent({
@@ -185,7 +190,7 @@ export const claudeCodeRuntime = {
185
190
  }
186
191
  },
187
192
  })
188
- : await this.runTurn({ sessionId: targetSessionId, projectDir, claudePath, agentEnv }, message, { appendSystemPrompt });
193
+ : await this.runTurn({ sessionId: targetSessionId, projectDir, claudePath, agentEnv }, prompt.message, { appendSystemPrompt: prompt.systemPrompt });
189
194
  const nextBinding = await updateBindingRuntimeMeta(ctx, binding, {
190
195
  sessionId: result?.sessionId || meta.sessionId,
191
196
  runtimePath: claudePath,
@@ -29,6 +29,7 @@ import {
29
29
  } from '../../core/runtime-support.mjs';
30
30
  import { buildAgentRuntimeEnv } from '../../core/runtime-env.mjs';
31
31
  import { buildRuntimeBaseInstructions } from '../_shared/runtime-base-instructions.mjs';
32
+ import { injectRuntimePrompt, PROMPT_INJECTION_STRATEGY } from '../_shared/prompt-injection.mjs';
32
33
  import { ensureAgentHome } from '../../core/agent-home.mjs';
33
34
 
34
35
  export const codexRuntime = {
@@ -168,11 +169,18 @@ export const codexRuntime = {
168
169
  sessionId: shouldRotate ? null : meta.sessionId,
169
170
  hostId: binding.runtime_host_id,
170
171
  });
171
- const developerInstructions = buildRuntimeBaseInstructions({ agentId: binding.id });
172
- const result = (shouldRotate || inbound.action === 'image' || shouldStreamRuntime(this.name, this))
173
- ? await this.runTurnStream({ sessionId: shouldRotate ? null : meta.sessionId, cwd: agentHome, codexPath: runtimeCodexPath, agentEnv }, message, {
172
+ const useAppServer = shouldRotate || inbound.action === 'image' || shouldStreamRuntime(this.name, this);
173
+ const prompt = injectRuntimePrompt({
174
+ message,
175
+ runtimeBaseInstructions: buildRuntimeBaseInstructions({ agentId: binding.id }),
176
+ strategy: useAppServer
177
+ ? PROMPT_INJECTION_STRATEGY.NATIVE_SYSTEM
178
+ : PROMPT_INJECTION_STRATEGY.PREPEND_EVERY_TURN,
179
+ });
180
+ const result = useAppServer
181
+ ? await this.runTurnStream({ sessionId: shouldRotate ? null : meta.sessionId, cwd: agentHome, codexPath: runtimeCodexPath, agentEnv }, prompt.message, {
174
182
  input: codexInput,
175
- developerInstructions,
183
+ developerInstructions: prompt.systemPrompt,
176
184
  onEvent: async (event) => {
177
185
  if (event?.type === 'turn.started') {
178
186
  await emitWorkerEvent({
@@ -198,7 +206,7 @@ export const codexRuntime = {
198
206
  }
199
207
  },
200
208
  })
201
- : await this.runTurn({ sessionId: shouldRotate ? null : meta.sessionId, cwd: agentHome, codexPath: runtimeCodexPath, agentEnv }, message, {
209
+ : await this.runTurn({ sessionId: shouldRotate ? null : meta.sessionId, cwd: agentHome, codexPath: runtimeCodexPath, agentEnv }, prompt.message, {
202
210
  input: codexInput,
203
211
  });
204
212
 
@@ -1,5 +1,6 @@
1
1
  import { reportSubprocessFailure, terminalRuntimeFailure } from '../../core/runtime-support.mjs';
2
2
  import { buildRuntimeBaseInstructions } from '../_shared/runtime-base-instructions.mjs';
3
+ import { injectRuntimePrompt, PROMPT_INJECTION_STRATEGY } from '../_shared/prompt-injection.mjs';
3
4
  import { GATEWAY_HOST, GATEWAY_PORT } from './identity.mjs';
4
5
 
5
6
  // Cheap availability probe used by the harness picker. We can't use
@@ -24,11 +25,6 @@ async function probeOpenClawGatewayHealth() {
24
25
  import { buildOpenClawSessionKey, normalizeOpenClawAgentId } from './target.mjs';
25
26
  import { askGateway, isGatewayReady, registerOpenClawChannel } from './gateway.mjs';
26
27
 
27
- // Tracks which (agentId, sessionKey) pairs already saw the runtime base
28
- // instructions this process lifetime. OpenClaw's gateway holds session state
29
- // out of process, so we err on the side of "inject on first observed
30
- // turn after daemon restart"; the gateway dedupes redundant context.
31
- const runtimeBaseInstructionsSeen = new Set();
32
28
  import { addInFlight, recoverInFlightEntries, removeInFlight } from './inflight.mjs';
33
29
  import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
34
30
  import { extname } from 'node:path';
@@ -158,17 +154,13 @@ export const openClawRuntime = {
158
154
  const agentId = normalizeOpenClawAgentId(meta.agentId || binding.id);
159
155
  const sessionId = String(meta.sessionKey || buildOpenClawSessionKey(agentId)).trim();
160
156
 
161
- // Inject the runtime base instructions on the first observed turn after a
162
- // daemon start for this (agent, session) pair. OpenClaw has no
163
- // separate "system prompt" parameter on the gateway, so we prepend
164
- // exactly once.
165
- const runtimeBaseInstructionsKey = `${agentId}|${sessionId}`;
166
- let prompt = rawPrompt;
167
- if (!runtimeBaseInstructionsSeen.has(runtimeBaseInstructionsKey)) {
168
- const baseInstructions = buildRuntimeBaseInstructions({ agentId: binding.id });
169
- prompt = `${baseInstructions}\n\n---\n\n${rawPrompt}`;
170
- runtimeBaseInstructionsSeen.add(runtimeBaseInstructionsKey);
171
- }
157
+ // OpenClaw has no separate system-prompt parameter, so inject the
158
+ // Ticlawk runtime contract into every routed turn body.
159
+ const prompt = injectRuntimePrompt({
160
+ message: rawPrompt,
161
+ runtimeBaseInstructions: buildRuntimeBaseInstructions({ agentId: binding.id }),
162
+ strategy: PROMPT_INJECTION_STRATEGY.PREPEND_EVERY_TURN,
163
+ }).message;
172
164
 
173
165
  if (inbound.messageId) {
174
166
  addInFlight({
@@ -33,6 +33,7 @@ import { debugLog, debugError } from '../../core/logger.mjs';
33
33
  import { buildRuntimeEnv } from '../../core/runtime-env.mjs';
34
34
  import { getRuntimeExecutableConfig } from '../../core/config.mjs';
35
35
  import { isExecutablePath, resolveExecutable } from '../../core/executables.mjs';
36
+ import { injectRuntimePrompt, PROMPT_INJECTION_STRATEGY } from '../_shared/prompt-injection.mjs';
36
37
 
37
38
  export const OPENCODE_DATA_DIR = process.env.OPENCODE_DATA_DIR
38
39
  || `${process.env.XDG_DATA_HOME || `${homedir()}/.local/share`}/opencode`;
@@ -213,13 +214,13 @@ export function runOpenCodePrompt({
213
214
  timeoutMs = Number(process.env.OPENCODE_RUN_TIMEOUT_MS || DEFAULT_OPENCODE_RUN_TIMEOUT_MS),
214
215
  onEvent,
215
216
  }) {
216
- // opencode has no documented `--system` flag, so we prepend the
217
- // runtime base instructions to the first-turn message body. Resumed sessions
218
- // (sessionId set) skip injection because the model already saw it
219
- // on the originating turn.
220
- const finalMessage = runtimeBaseInstructions && !sessionId
221
- ? `${runtimeBaseInstructions}\n\n---\n\n${message}`
222
- : message;
217
+ // opencode has no documented `--system` flag, so inject the Ticlawk
218
+ // runtime contract into every routed turn body.
219
+ const finalMessage = injectRuntimePrompt({
220
+ message,
221
+ runtimeBaseInstructions,
222
+ strategy: PROMPT_INJECTION_STRATEGY.PREPEND_EVERY_TURN,
223
+ }).message;
223
224
  return new Promise((resolve, reject) => {
224
225
  const startedAt = Date.now();
225
226
  const opencodeCommand = requireOpenCodePath(opencodePath);
@@ -15,6 +15,7 @@ import { randomUUID } from 'node:crypto';
15
15
  import { buildRuntimeEnv } from '../../core/runtime-env.mjs';
16
16
  import { getRuntimeExecutableConfig } from '../../core/config.mjs';
17
17
  import { isExecutablePath, resolveExecutable } from '../../core/executables.mjs';
18
+ import { injectRuntimePrompt, PROMPT_INJECTION_STRATEGY } from '../_shared/prompt-injection.mjs';
18
19
 
19
20
  export const DEFAULT_PI_COMMAND = 'pi';
20
21
  export const PI_AGENT_DIR = process.env.PI_CODING_AGENT_DIR || `${homedir()}/.pi/agent`;
@@ -200,10 +201,13 @@ export function runPiPrompt({
200
201
  timeoutMs = Number(process.env.PI_RUN_TIMEOUT_MS || DEFAULT_PI_RUN_TIMEOUT_MS),
201
202
  onEvent,
202
203
  }) {
203
- // pi has no documented system-prompt flag prepend on first turn.
204
- const finalMessage = runtimeBaseInstructions && !sessionId
205
- ? `${runtimeBaseInstructions}\n\n---\n\n${message}`
206
- : message;
204
+ // pi has no documented system-prompt flag, so inject the Ticlawk
205
+ // runtime contract into every routed turn body.
206
+ const finalMessage = injectRuntimePrompt({
207
+ message,
208
+ runtimeBaseInstructions,
209
+ strategy: PROMPT_INJECTION_STRATEGY.PREPEND_EVERY_TURN,
210
+ }).message;
207
211
  return new Promise((resolve, reject) => {
208
212
  const startedAt = Date.now();
209
213
  const piCommand = requirePiPath(piPath);