anolisa-tokenless 0.7.13 → 0.8.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.
Files changed (34) hide show
  1. package/README.md +219 -87
  2. package/adapters/tokenless/claude-code/.claude-plugin/plugin.json +1 -1
  3. package/adapters/tokenless/claude-code/hooks/run-hook.sh +62 -0
  4. package/adapters/tokenless/codex/.codex-plugin/plugin.json +5 -4
  5. package/adapters/tokenless/codex/README.md +22 -32
  6. package/adapters/tokenless/codex/hooks/hooks.json +3 -3
  7. package/adapters/tokenless/codex/scripts/response-diagnostics +170 -0
  8. package/adapters/tokenless/common/cosh-extension.json +4 -4
  9. package/adapters/tokenless/common/hooks/compress_response_hook.py +250 -307
  10. package/adapters/tokenless/common/hooks/compress_schema_hook.py +34 -42
  11. package/adapters/tokenless/common/hooks/hook_utils.py +281 -44
  12. package/adapters/tokenless/common/hooks/rewrite_hook.py +53 -169
  13. package/adapters/tokenless/dsh/dist/index.js +353 -264
  14. package/adapters/tokenless/dsh/package.json +2 -2
  15. package/adapters/tokenless/hermes/__init__.py +191 -355
  16. package/adapters/tokenless/hermes/plugin.yaml +2 -2
  17. package/adapters/tokenless/manifest.json +18 -3
  18. package/adapters/tokenless/openclaw/dist/index.d.ts +4 -16
  19. package/adapters/tokenless/openclaw/dist/index.js +291 -507
  20. package/adapters/tokenless/openclaw/index.ts +408 -628
  21. package/adapters/tokenless/openclaw/openclaw.plugin.json +4 -20
  22. package/adapters/tokenless/openclaw/package.json +6 -4
  23. package/adapters/tokenless/qoder/.qoder-plugin/plugin.json +1 -1
  24. package/adapters/tokenless/qwencode/hooks/run-hook.sh +62 -0
  25. package/adapters/tokenless/qwencode/qwen-extension.json +4 -4
  26. package/adapters/tokenless/qwenpaw/plugin.json +17 -0
  27. package/adapters/tokenless/qwenpaw/plugin.py +390 -0
  28. package/adapters/tokenless/qwenpaw/requirements.txt +6 -0
  29. package/adapters/tokenless/qwenpaw/scripts/detect.sh +131 -0
  30. package/adapters/tokenless/qwenpaw/scripts/install.sh +98 -0
  31. package/adapters/tokenless/qwenpaw/scripts/uninstall.sh +61 -0
  32. package/package.json +5 -5
  33. package/adapters/tokenless/codex/scripts/compress-response +0 -445
  34. package/adapters/tokenless/common/hooks/compress_toon_hook.py +0 -171
@@ -1,51 +1,56 @@
1
1
  /**
2
2
  * Native Tokenless plugin for DeepSeek Harness (dsh).
3
3
  *
4
- * This entry intentionally has no dsh runtime imports. dsh supplies the
4
+ * This entry intentionally has no dsh runtime imports. DSH supplies the
5
5
  * Cordis event types at runtime, while the only process boundary is the
6
- * installed Tokenless CLI. Keeping the entry dependency-free lets ANOLISA
6
+ * installed Tokenless CLI. Keeping the entry dependency-free lets ANOLISA
7
7
  * install one self-contained bundle without running npm in $DSH_HOME.
8
8
  */
9
9
  import { execFile } from 'node:child_process'
10
10
  import { randomUUID } from 'node:crypto'
11
+ import {
12
+ accessSync,
13
+ appendFileSync,
14
+ constants,
15
+ lstatSync,
16
+ mkdirSync,
17
+ readFileSync,
18
+ statSync,
19
+ writeFileSync,
20
+ } from 'node:fs'
21
+ import { delimiter, isAbsolute, join, resolve } from 'node:path'
11
22
 
12
23
  const PLUGIN_NAME = 'anolisa-tokenless'
13
24
  const DEFAULT_AGENT_ID = 'dsh'
14
25
  const DEFAULT_TIMEOUT_MS = 3000
15
26
  const DEFAULT_MAX_BUFFER = 2 * 1024 * 1024
27
+ const DSH_TOKENLESS_DATA_DIR = 'DSH_TOKENLESS_DATA_DIR'
28
+ const DSH_TOKENLESS_STATS_DB = 'DSH_TOKENLESS_STATS_DB'
29
+ const DSH_TOKENLESS_STASH_DB = 'DSH_TOKENLESS_STASH_DB'
30
+ const TOKENLESS_RETRIEVE_COMMAND_RE = /^[ \t]*(?:"tokenless"|'tokenless'|tokenless)[ \t]+retrieve[ \t]+(?:"(?:[0-9a-f]{24}|<<tokenless:[0-9a-f]{24}>>)"|'(?:[0-9a-f]{24}|<<tokenless:[0-9a-f]{24}>>)'|[0-9a-f]{24})[ \t]*$/i
16
31
 
17
- // Keep content-retrieval tools lossless. These names mirror the shared
18
- // Tokenless adapter taxonomy; callers may extend or replace the list through
19
- // the dsh plugin config without changing this safety default.
20
- const DEFAULT_SKIP_TOOLS = new Set([
21
- 'Read',
32
+ // DSH does not expose content origin, so map its built-in tool names at the
33
+ // host boundary. Core owns every compression decision after this translation.
34
+ const FILE_TOOLS = new Set([
22
35
  'read',
23
36
  'read_file',
24
37
  'read_many_files',
25
- 'Glob',
26
38
  'glob',
27
39
  'search_file',
28
40
  'list_directory',
29
41
  'list_dir',
30
- 'Grep',
31
42
  'grep',
32
43
  'grep_code',
33
44
  'grep_search',
34
45
  'search_files',
35
- 'Lsp',
36
46
  'lsp',
37
- 'NotebookRead',
38
- 'notebook_read',
39
47
  'notebookread',
48
+ 'notebook_read',
40
49
  ])
41
50
 
42
- // These values are the same thresholds used by the shared hook adapter. A
43
- // dsh profile can override them in its plugin config; the Tokenless CLI still
44
- // owns the actual compression algorithm and stats recording.
45
- const DEFAULT_SHELL_TOOLS = new Set([
46
- 'Bash',
51
+ const COMMAND_TOOLS = new Set([
47
52
  'bash',
48
- 'Shell',
53
+ 'pwsh',
49
54
  'shell',
50
55
  'exec',
51
56
  'terminal',
@@ -56,84 +61,7 @@ const DEFAULT_SHELL_TOOLS = new Set([
56
61
  'process',
57
62
  ])
58
63
 
59
- const DEFAULT_THRESHOLDS = {
60
- shell: { strings: 65536, arrays: 128, depth: 8 },
61
- api: { strings: 1048576, arrays: 65536, depth: 32 },
62
- }
63
-
64
- // Mirror common/hooks/hook_utils.py ENV_PATTERNS exactly. The dsh bundle is
65
- // independently publishable, so changes to the canonical table must update
66
- // this dependency-free runtime copy and its tests together.
67
- const ENV_PATTERNS = [
68
- [
69
- [
70
- /command not found/i,
71
- /not installed/i,
72
- /which:\s+no/i,
73
- /no command\s/i,
74
- /cannot execute/i,
75
- /is not recognized/i,
76
- /could not find/i,
77
- /unable to locate/i,
78
- /package not found/i,
79
- /\/bin\/sh:.*: not found/i,
80
- /command not found:/i,
81
- ],
82
- 'ENV_DEPENDENCY_MISSING',
83
- 'Missing dependency detected. Install it or ask the user for guidance.',
84
- ],
85
- [
86
- [
87
- /permission denied/i,
88
- /operation not permitted/i,
89
- /eacces/i,
90
- /access denied/i,
91
- /cannot open .* for writing/i,
92
- ],
93
- 'ENV_PERMISSION',
94
- 'Permission denied. Check file/directory permissions or run with appropriate access.',
95
- ],
96
- [
97
- [
98
- /no such file or directory/i,
99
- /enoent/i,
100
- /cannot find/i,
101
- /file not found/i,
102
- /does not exist/i,
103
- ],
104
- 'ENV_FILE_MISSING',
105
- 'Required file or directory not found. Verify the path or create it.',
106
- ],
107
- [
108
- [
109
- /connection refused/i,
110
- /could not resolve host/i,
111
- /network is unreachable/i,
112
- /curl: \(7\)/i,
113
- /curl: \(6\)/i,
114
- /failed to connect/i,
115
- /name or service not known/i,
116
- /couldn't resolve host/i,
117
- /temporary failure in name resolution/i,
118
- /econnrefused/i,
119
- /etimedout/i,
120
- /connection timed out/i,
121
- ],
122
- 'ENV_NETWORK',
123
- 'Network connectivity issue. Check DNS, proxy, and firewall settings.',
124
- ],
125
- [
126
- [
127
- /modulenotfounderror/i,
128
- /importerror/i,
129
- /no module named/i,
130
- /cannot import name/i,
131
- /npm err! 404/i,
132
- ],
133
- 'ENV_PACKAGE_MISSING',
134
- 'Required package or module is missing. Install the needed dependency.',
135
- ],
136
- ]
64
+ const INTERRUPTED_CODES = new Set(['ABORTED', 'ABORTED_BEFORE_DISPATCH'])
137
65
 
138
66
  /** Return a plain config value or the supplied fallback. */
139
67
  function valueOr(config, key, fallback) {
@@ -142,80 +70,187 @@ function valueOr(config, key, fallback) {
142
70
  : fallback
143
71
  }
144
72
 
145
- /** Normalize a config tool list without allowing malformed values to widen it. */
146
- function toolSet(value, fallback) {
147
- if (!Array.isArray(value)) return fallback
148
- return new Set(value.filter((name) => typeof name === 'string' && name.length > 0))
149
- }
150
-
151
- /** Resolve the executable without ever installing or mutating a dsh profile. */
73
+ /** Resolve the executable without installing or mutating a DSH profile. */
152
74
  function tokenlessBinary(config) {
153
75
  const configured = valueOr(config, 'tokenlessBin', undefined)
154
76
  if (typeof configured === 'string' && configured.length > 0) return configured
155
77
  return process.env.TOKENLESS_BIN || 'tokenless'
156
78
  }
157
79
 
158
- /** Extract text used only for error attribution; never stringify image blocks. */
159
- function errorText(result) {
160
- if (!result || typeof result !== 'object') return ''
161
- const error = result.error
162
- if (error && typeof error.message === 'string') return error.message
163
- return result.content
164
- ?.filter((block) => block && block.type === 'text' && typeof block.text === 'string')
165
- .map((block) => block.text)
166
- .join('\n') || ''
80
+ /** Keep Core and DSH's sandboxed shell on the same writable state directory. */
81
+ function tokenlessDataDir(exec) {
82
+ const configured = process.env.TOKENLESS_DATA_DIR
83
+ if (typeof configured === 'string' && configured.length > 0) return configured
84
+ const sessionCwd = exec.agent?.session?.header?.cwd
85
+ const workspace = typeof sessionCwd === 'string' && isAbsolute(sessionCwd)
86
+ ? sessionCwd
87
+ : process.cwd()
88
+ return join(workspace, '.tokenless')
89
+ }
90
+
91
+ /** Prevent default workspace state from becoming a source-control candidate. */
92
+ function prepareTokenlessDataDir(exec) {
93
+ const dataDir = tokenlessDataDir(exec)
94
+ if (typeof process.env.TOKENLESS_DATA_DIR === 'string'
95
+ && process.env.TOKENLESS_DATA_DIR.length > 0) return dataDir
96
+
97
+ mkdirSync(dataDir, { recursive: true, mode: 0o700 })
98
+ if (!lstatSync(dataDir).isDirectory()) {
99
+ throw new Error(`Tokenless state path is not a directory: ${dataDir}`)
100
+ }
101
+ const ignorePath = join(dataDir, '.gitignore')
102
+ try {
103
+ writeFileSync(ignorePath, '*\n', { encoding: 'utf8', flag: 'wx', mode: 0o600 })
104
+ } catch (error) {
105
+ if (error?.code !== 'EEXIST') throw error
106
+ const metadata = lstatSync(ignorePath)
107
+ if (!metadata.isFile() || metadata.isSymbolicLink()) {
108
+ throw new Error(`Tokenless state ignore file is unsafe: ${ignorePath}`)
109
+ }
110
+ const contents = readFileSync(ignorePath, 'utf8')
111
+ if (!contents.split(/\r?\n/).includes('*')) {
112
+ appendFileSync(
113
+ ignorePath,
114
+ `${contents.endsWith('\n') || contents.length === 0 ? '' : '\n'}*\n`,
115
+ )
116
+ }
117
+ }
118
+ return dataDir
167
119
  }
168
120
 
169
- /** Return an environment category and remediation hint for known failure text. */
170
- function classifyEnvironmentError(text) {
171
- if (typeof text !== 'string') return undefined
172
- if (!text) return undefined
173
- for (const [patterns, category, hint] of ENV_PATTERNS) {
174
- if (patterns.some((pattern) => pattern.test(text))) return { category, hint }
121
+ /** Publish the state overrides that DSH strips from model shell commands. */
122
+ function tokenlessShellEnvironment(exec) {
123
+ const environment = {
124
+ [DSH_TOKENLESS_DATA_DIR]: tokenlessDataDir(exec),
125
+ }
126
+ for (const [source, target] of [
127
+ ['TOKENLESS_STATS_DB', DSH_TOKENLESS_STATS_DB],
128
+ ['TOKENLESS_STASH_DB', DSH_TOKENLESS_STASH_DB],
129
+ ]) {
130
+ const value = process.env[source]
131
+ if (typeof value === 'string' && value.length > 0) environment[target] = value
132
+ }
133
+ return environment
134
+ }
135
+
136
+ /** Resolve one bare executable using the path inherited by DSH shell commands. */
137
+ function executableOnPath(name) {
138
+ if (typeof process.env.PATH !== 'string') return undefined
139
+ for (const directory of process.env.PATH.split(delimiter)) {
140
+ // A future Marker call may use a different shell workdir, so a
141
+ // cwd-relative entry cannot establish a stable executable identity.
142
+ if (!isAbsolute(directory)) return undefined
143
+ const candidate = join(directory, name)
144
+ try {
145
+ accessSync(candidate, constants.X_OK)
146
+ if (statSync(candidate).isFile()) return candidate
147
+ } catch {
148
+ continue
149
+ }
175
150
  }
176
151
  return undefined
177
152
  }
178
153
 
179
- /** Extract attribution only when structured output explicitly reports failure. */
180
- function classifyStructuredEnvironmentError(value) {
181
- if (!value || typeof value !== 'object' || Array.isArray(value)) return undefined
154
+ /** Require compression and Marker recovery to invoke the same CLI file. */
155
+ function tokenlessRetrieveCommandAvailable(config) {
156
+ const markerBinary = executableOnPath('tokenless')
157
+ if (markerBinary === undefined) return false
158
+ const selected = tokenlessBinary(config)
159
+ const selectedBinary = isAbsolute(selected)
160
+ ? selected
161
+ : (selected.includes('/') ? resolve(selected) : executableOnPath(selected))
162
+ if (selectedBinary === undefined) return false
163
+ try {
164
+ accessSync(selectedBinary, constants.X_OK)
165
+ const markerStat = statSync(markerBinary)
166
+ const selectedStat = statSync(selectedBinary)
167
+ return selectedStat.isFile()
168
+ && markerStat.dev === selectedStat.dev
169
+ && markerStat.ino === selectedStat.ino
170
+ } catch {
171
+ return false
172
+ }
173
+ }
174
+
175
+ /** Convert one process limit to a positive finite integer. */
176
+ function positiveInteger(value, fallback) {
177
+ return Number.isInteger(value) && value > 0 ? value : fallback
178
+ }
179
+
180
+ /** Return the only DSH content shape that can be replaced without schema loss. */
181
+ function singleText(content) {
182
+ if (!Array.isArray(content) || content.length !== 1) return undefined
183
+ const [block] = content
184
+ if (!block || block.type !== 'text' || typeof block.text !== 'string') return undefined
185
+ return block.text
186
+ }
187
+
188
+ /** Treat shell-shaped values as execution status only for known command tools. */
189
+ function commandFailed(value) {
190
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return false
182
191
  const exitCode = value.exit_code ?? value.exitCode
183
- const stringExitCode = typeof exitCode === 'string' ? exitCode.trim() : ''
184
- const nonzeroExit = (typeof exitCode === 'number' && exitCode !== 0)
185
- || (/^-?\d+$/.test(stringExitCode) && Number(stringExitCode) !== 0)
186
- const timedOut = value.timed_out === true || value.timedOut === true
187
- const failed = nonzeroExit
188
- || timedOut
192
+ const numericExit = typeof exitCode === 'number'
193
+ ? exitCode
194
+ : (typeof exitCode === 'string' && /^-?\d+$/.test(exitCode.trim())
195
+ ? Number(exitCode)
196
+ : 0)
197
+ return numericExit !== 0
198
+ || (typeof value.signal === 'string' && value.signal.length > 0)
199
+ || value.timed_out === true
200
+ || value.timedOut === true
189
201
  || value.isError === true
190
202
  || value.success === false
191
203
  || value.ok === false
192
- if (!failed) return undefined
193
- const error = value.error
194
- let errorValue = error
195
- if (error && typeof error === 'object') {
196
- if (typeof error.message === 'string') {
197
- errorValue = error.message
198
- } else {
204
+ }
205
+
206
+ /** Extract the failure payload while leaving diagnostic classification to Core. */
207
+ function failureText(result, value) {
208
+ const parts = []
209
+ if (result?.isError === true && typeof result.error?.message === 'string') {
210
+ parts.push(result.error.message)
211
+ }
212
+ if (Array.isArray(result?.content)) {
213
+ parts.push(...result.content
214
+ .filter((block) => block?.type === 'text' && typeof block.text === 'string')
215
+ .map((block) => block.text))
216
+ }
217
+ if (value && typeof value === 'object' && !Array.isArray(value)) {
218
+ const stderr = value.stderr
219
+ if (typeof stderr === 'string') {
220
+ parts.push(stderr)
221
+ } else if (stderr && typeof stderr.text === 'string') {
222
+ parts.push(stderr.text)
223
+ }
224
+ if (typeof value.error === 'string') {
225
+ parts.push(value.error)
226
+ } else if (value.error && typeof value.error.message === 'string') {
227
+ parts.push(value.error.message)
228
+ } else if (value.error && typeof value.error === 'object') {
229
+ let serialized
199
230
  try {
200
- errorValue = JSON.stringify(error)
231
+ serialized = JSON.stringify(value.error)
201
232
  } catch {
202
- errorValue = String(error)
233
+ serialized = String(value.error)
234
+ }
235
+ if (typeof serialized === 'string') parts.push(serialized)
236
+ }
237
+ if (typeof value.signal === 'string' && value.signal.length > 0) {
238
+ parts.push(`terminated by signal: ${value.signal}`)
239
+ }
240
+ if (!parts.some((part) => part.length > 0)) {
241
+ const stdout = value.stdout
242
+ if (typeof stdout === 'string') {
243
+ parts.push(stdout)
244
+ } else if (stdout && typeof stdout.text === 'string') {
245
+ parts.push(stdout.text)
203
246
  }
204
247
  }
205
248
  }
206
- const streamText = (stream) => {
207
- if (typeof stream === 'string') return stream
208
- if (stream && typeof stream === 'object' && typeof stream.text === 'string') return stream.text
209
- return undefined
210
- }
211
- const text = [streamText(value.stderr), errorValue]
212
- .filter((part) => typeof part === 'string')
213
- .join('\n')
214
- return classifyEnvironmentError(text)
249
+ return parts.filter((part) => part.length > 0).join('\n')
215
250
  }
216
251
 
217
- /** Construct a valid plugin-owned user message without importing dsh modules. */
218
- function attributionContext(text) {
252
+ /** Construct a valid plugin-owned user message without importing DSH modules. */
253
+ function diagnosticContext(text) {
219
254
  return {
220
255
  id: randomUUID(),
221
256
  role: 'user',
@@ -229,171 +264,225 @@ function attributionContext(text) {
229
264
  }
230
265
  }
231
266
 
232
- /** Add an attribution context to a decision while preserving its shape. */
233
- function withAttribution(decision, attribution) {
234
- if (!attribution) return decision
267
+ /** Add Core's diagnostic to a decision while preserving waterfall output. */
268
+ function withDiagnostic(decision, diagnostic) {
269
+ if (typeof diagnostic !== 'string' || diagnostic.length === 0) return decision
235
270
  return {
236
271
  ...decision,
237
272
  additionalContexts: [
238
273
  ...(Array.isArray(decision.additionalContexts) ? decision.additionalContexts : []),
239
- attributionContext(attribution),
274
+ diagnosticContext(diagnostic),
240
275
  ],
241
276
  }
242
277
  }
243
278
 
244
- /** Safely read one text-only result projection. */
245
- function singleTextContent(result) {
246
- if (!result || result.isError || !Array.isArray(result.content)) return undefined
247
- if (result.content.length !== 1) return undefined
248
- const [block] = result.content
249
- if (!block || block.type !== 'text' || typeof block.text !== 'string') return undefined
250
- return block.text
251
- }
252
-
253
- /** Convert one config threshold to a positive finite integer. */
254
- function positiveInteger(value, fallback) {
255
- return Number.isInteger(value) && value > 0 ? value : fallback
279
+ /** Map a DSH tool name to the explicit PostTool content origin. */
280
+ function contentOrigin(toolName) {
281
+ const normalized = toolName.toLowerCase()
282
+ if (FILE_TOOLS.has(normalized)) return 'file_content'
283
+ if (COMMAND_TOOLS.has(normalized)) return 'command_output'
284
+ return 'api_response'
256
285
  }
257
286
 
258
- /** Build the Tokenless CLI argv for one dsh execution. */
259
- function compressionArgs(exec, config, shellTools) {
260
- const selected = shellTools.has(exec.name) ? DEFAULT_THRESHOLDS.shell : DEFAULT_THRESHOLDS.api
261
- const strings = positiveInteger(valueOr(config, 'truncateStringsAt', undefined), selected.strings)
262
- const arrays = positiveInteger(valueOr(config, 'truncateArraysAt', undefined), selected.arrays)
263
- const depth = positiveInteger(valueOr(config, 'maxDepth', undefined), selected.depth)
264
- const args = [
265
- 'compress-response',
266
- '--agent-id',
267
- String(valueOr(config, 'agentId', DEFAULT_AGENT_ID)),
268
- '--truncate-strings-at',
269
- String(strings),
270
- '--truncate-arrays-at',
271
- String(arrays),
272
- '--max-depth',
273
- String(depth),
274
- ]
275
- const sessionId = exec.agent?.id
276
- if (typeof sessionId === 'string' && sessionId.length > 0) {
277
- args.push('--session-id', sessionId)
278
- }
279
- if (typeof exec.callId === 'string' && exec.callId.length > 0) {
280
- args.push('--tool-use-id', exec.callId)
281
- }
282
- if (valueOr(config, 'noStash', false) === true) args.push('--no-stash')
283
- return args
287
+ /** Recognize the exact local recovery command emitted by Tokenless markers. */
288
+ function isTokenlessRetrieveCommand(toolName, args) {
289
+ if (!COMMAND_TOOLS.has(toolName.toLowerCase())) return false
290
+ if (!args || typeof args !== 'object' || Array.isArray(args)) return false
291
+ if (typeof args.command !== 'string') return false
292
+ return TOKENLESS_RETRIEVE_COMMAND_RE.test(args.command)
284
293
  }
285
294
 
286
295
  /** Execute a child process with bounded output and explicit stdin. */
287
- function runTokenless(binary, args, options, input) {
296
+ function runTokenless(binary, request, options) {
288
297
  return new Promise((resolve, reject) => {
289
298
  let child
290
299
  try {
291
- child = execFile(binary, args, options, (error, stdout, stderr) => {
300
+ child = execFile(binary, ['compress'], options, (error, stdout) => {
292
301
  if (error) {
293
- error.stderr = stderr
294
302
  reject(error)
295
303
  return
296
304
  }
297
- resolve({ stdout, stderr })
305
+ resolve(stdout)
298
306
  })
299
307
  } catch (error) {
300
308
  reject(error)
301
309
  return
302
310
  }
303
311
  child.stdin?.on('error', () => {})
304
- try {
305
- child.stdin?.end(input)
306
- } catch (error) {
307
- reject(error)
308
- }
312
+ child.stdin?.end(JSON.stringify(request))
309
313
  })
310
314
  }
311
315
 
312
- /** Run Tokenless and return a strictly smaller JSON candidate, or undefined. */
313
- async function compressText(text, exec, config, shellTools) {
314
- if (exec.signal?.aborted) return undefined
315
- let parsed
316
- try {
317
- parsed = JSON.parse(text)
318
- } catch {
319
- return undefined
320
- }
321
- if (parsed === null || (typeof parsed !== 'object' && !Array.isArray(parsed))) return undefined
322
- const binary = tokenlessBinary(config)
316
+ /** Run one PostTool operation and reject malformed transport responses. */
317
+ async function runPostTool(request, exec, config) {
323
318
  try {
324
- const { stdout } = await runTokenless(binary, compressionArgs(exec, config, shellTools), {
319
+ const stdout = await runTokenless(tokenlessBinary(config), request, {
325
320
  timeout: positiveInteger(valueOr(config, 'timeoutMs', undefined), DEFAULT_TIMEOUT_MS),
326
321
  maxBuffer: positiveInteger(valueOr(config, 'maxBuffer', undefined), DEFAULT_MAX_BUFFER),
327
322
  encoding: 'utf8',
328
323
  windowsHide: true,
329
324
  signal: exec.signal,
330
- }, text)
331
- const candidate = typeof stdout === 'string' ? stdout.trim() : ''
332
- // The host's original content is authoritative unless compression proves
333
- // a real reduction. This avoids duplicate payloads and preserves fail-open
334
- // behavior when the CLI is unavailable, malformed, or no-op.
335
- if (!candidate || candidate.length >= text.length) return undefined
336
- JSON.parse(candidate)
337
- return candidate
325
+ env: {
326
+ ...process.env,
327
+ TOKENLESS_DATA_DIR: prepareTokenlessDataDir(exec),
328
+ },
329
+ })
330
+ const response = JSON.parse(stdout)
331
+ if (!response || typeof response !== 'object' || Array.isArray(response)) return undefined
332
+ if (response.protocol_version !== 2 || response.operation !== 'post_tool') return undefined
333
+ const result = response.result
334
+ if (!result || typeof result !== 'object' || Array.isArray(result)) return undefined
335
+ return result
338
336
  } catch {
339
337
  return undefined
340
338
  }
341
339
  }
342
340
 
343
- /** Register native response compression on dsh's typed post-execute seam. */
341
+ /** Build one operation request from the DSH execution boundary. */
342
+ function postToolRequest(
343
+ exec,
344
+ config,
345
+ content,
346
+ status,
347
+ origin,
348
+ replaceOutput,
349
+ resultKind,
350
+ retrievalAvailable,
351
+ ) {
352
+ const attribution = {
353
+ agent_id: String(valueOr(config, 'agentId', DEFAULT_AGENT_ID)),
354
+ }
355
+ if (typeof exec.agent?.id === 'string' && exec.agent.id.length > 0) {
356
+ attribution.session_id = exec.agent.id
357
+ }
358
+ if (typeof exec.callId === 'string' && exec.callId.length > 0) {
359
+ attribution.tool_use_id = exec.callId
360
+ }
361
+ return {
362
+ protocol_version: 2,
363
+ operation: 'post_tool',
364
+ attribution,
365
+ input: {
366
+ result_kind: resultKind,
367
+ tool_name: exec.name,
368
+ content,
369
+ status,
370
+ content_origin: origin,
371
+ output_optimization: 'none',
372
+ capabilities: {
373
+ replace_output: replaceOutput,
374
+ recovery: { kind: retrievalAvailable ? "shell" : "none" },
375
+ replace_with_text: replaceOutput,
376
+ },
377
+ },
378
+ }
379
+ }
380
+
381
+ /** Register DSH's PostTool seam as a thin Tokenless lifecycle adapter. */
344
382
  export function apply(ctx, config = {}) {
345
- const skipTools = toolSet(valueOr(config, 'skipTools', undefined), DEFAULT_SKIP_TOOLS)
346
- const shellTools = toolSet(valueOr(config, 'shellTools', undefined), DEFAULT_SHELL_TOOLS)
347
- const enabled = valueOr(config, 'responseCompressionEnabled', true) !== false
348
- ctx.on('tools/post-execute', async (exec, result, next) => {
349
- const envError = result?.isError === true
350
- ? classifyEnvironmentError(errorText(result))
351
- : undefined
352
- const structuredError = result?.isError === false && shellTools.has(exec.name)
353
- ? classifyStructuredEnvironmentError(result.value)
354
- : undefined
355
- // Attribution is independent of compression so disabled, skipped, and
356
- // parented failures still tell the agent why blind retries are unsafe.
357
- const originalAttribution = structuredError || envError
383
+ const compressionEnabled = valueOr(config, 'responseCompressionEnabled', true) !== false
384
+
385
+ ctx.shellEnv.register({
386
+ name: PLUGIN_NAME,
387
+ variables: {
388
+ [DSH_TOKENLESS_DATA_DIR]: {
389
+ description: 'Workspace-local Tokenless state used by Marker recovery.',
390
+ },
391
+ [DSH_TOKENLESS_STATS_DB]: {
392
+ description: 'Tokenless statistics database selected by the DSH host.',
393
+ },
394
+ [DSH_TOKENLESS_STASH_DB]: {
395
+ description: 'Tokenless Stash database selected by the DSH host.',
396
+ },
397
+ },
398
+ resolve: tokenlessShellEnvironment,
399
+ })
358
400
 
359
- // DSH treats this seam as a waterfall. Let downstream policies settle
360
- // their decision before replacing only its accepted display content.
401
+ ctx.on('tools/post-execute', async (exec, result, next) => {
402
+ // DSH post-execute is a waterfall; later policies own the final projection.
361
403
  const decision = await next()
404
+ if (decision.kind !== 'accept' || exec.signal?.aborted) return decision
405
+
406
+ const toolName = exec.name.toLowerCase()
407
+ const isCommand = COMMAND_TOOLS.has(toolName)
362
408
  const replacesValue = Object.prototype.hasOwnProperty.call(decision, 'value')
363
- // A downstream canonical value replaces the original result entirely, so
364
- // any attribution must describe that value rather than the stale result.
365
- const attribution = replacesValue
366
- ? (shellTools.has(exec.name)
367
- ? classifyStructuredEnvironmentError(decision.value)
368
- : undefined)
369
- : originalAttribution
370
- const responseContext = attribution
371
- ? `[tokenless:env] ${exec.name} failed: ${attribution.category} (${attribution.hint}). Skip retry.`
372
- : undefined
373
- const canCompress = enabled
374
- && !skipTools.has(exec.name)
375
- && exec.parent === undefined
376
- && decision.kind === 'accept'
377
- && !replacesValue
378
- if (!canCompress) return withAttribution(decision, responseContext)
379
-
380
- // Only a single text block is safe to replace. Images, tool-call blocks,
381
- // nested tool results, and mixed content remain untouched by design.
382
- const contentResult = decision.content === undefined
383
- ? result
384
- : { ...result, content: decision.content }
385
- const original = singleTextContent(contentResult)
386
- if (original === undefined) return withAttribution(decision, responseContext)
409
+ const effectiveValue = replacesValue ? decision.value : result.value
410
+ const structuredFailure = isCommand && commandFailed(effectiveValue)
387
411
 
388
- const candidate = await compressText(original, exec, config, shellTools)
389
- if (!candidate) return withAttribution(decision, responseContext)
412
+ if (replacesValue) {
413
+ if (!structuredFailure) return decision
414
+ const content = failureText(undefined, effectiveValue)
415
+ const request = postToolRequest(
416
+ exec,
417
+ config,
418
+ content,
419
+ 'error',
420
+ 'command_output',
421
+ false,
422
+ 'tool',
423
+ false,
424
+ )
425
+ const response = await runPostTool(request, exec, config)
426
+ const diagnostic = response?.disposition === 'tool_error'
427
+ ? response.additional_context
428
+ : undefined
429
+ return withDiagnostic(decision, diagnostic)
430
+ }
431
+
432
+ const abortCode = result?.isError === true ? result.error?.info?.code : undefined
433
+ const interrupted = INTERRUPTED_CODES.has(abortCode)
434
+ const failed = result?.isError === true || structuredFailure
435
+ const status = interrupted ? 'interrupted' : (failed ? 'error' : 'success')
390
436
 
391
- return {
392
- ...withAttribution(decision, responseContext),
393
- content: [{ type: 'text', text: candidate }],
437
+ if (status === 'success') {
438
+ if (!compressionEnabled || exec.parent !== undefined) return decision
439
+ const effectiveContent = decision.content ?? result.content
440
+ const content = singleText(effectiveContent)
441
+ if (content === undefined) return decision
442
+ const retrieveResult = isTokenlessRetrieveCommand(exec.name, exec.arguments)
443
+ const request = postToolRequest(
444
+ exec,
445
+ config,
446
+ content,
447
+ status,
448
+ contentOrigin(exec.name),
449
+ true,
450
+ retrieveResult ? 'retrieve' : 'tool',
451
+ !retrieveResult && tokenlessRetrieveCommandAvailable(config),
452
+ )
453
+ const response = await runPostTool(request, exec, config)
454
+ if (response?.disposition !== 'applied' || typeof response.output !== 'string') {
455
+ return decision
456
+ }
457
+ return {
458
+ ...decision,
459
+ content: [{ type: 'text', text: response.output }],
460
+ }
394
461
  }
462
+
463
+ const effectiveResult = decision.content === undefined
464
+ ? result
465
+ : { ...result, content: decision.content }
466
+ const content = structuredFailure
467
+ ? failureText(undefined, effectiveValue)
468
+ : failureText(effectiveResult)
469
+ const request = postToolRequest(
470
+ exec,
471
+ config,
472
+ content,
473
+ status,
474
+ contentOrigin(exec.name),
475
+ false,
476
+ 'tool',
477
+ false,
478
+ )
479
+ const response = await runPostTool(request, exec, config)
480
+ const diagnostic = response?.disposition === 'tool_error'
481
+ ? response.additional_context
482
+ : undefined
483
+ return withDiagnostic(decision, diagnostic)
395
484
  })
396
485
  }
397
486
 
398
487
  export const name = PLUGIN_NAME
399
- export const inject = ['tools']
488
+ export const inject = ['tools', 'shellEnv']