codicent-cli 0.8.2__tar.gz → 0.8.4__tar.gz
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.
- {codicent_cli-0.8.2 → codicent_cli-0.8.4}/PKG-INFO +1 -1
- {codicent_cli-0.8.2 → codicent_cli-0.8.4}/app.py +546 -61
- {codicent_cli-0.8.2 → codicent_cli-0.8.4}/codicent_cli.egg-info/PKG-INFO +1 -1
- {codicent_cli-0.8.2 → codicent_cli-0.8.4}/setup.py +1 -1
- {codicent_cli-0.8.2 → codicent_cli-0.8.4}/terminal.py +8 -3
- {codicent_cli-0.8.2 → codicent_cli-0.8.4}/LICENSE +0 -0
- {codicent_cli-0.8.2 → codicent_cli-0.8.4}/README.md +0 -0
- {codicent_cli-0.8.2 → codicent_cli-0.8.4}/auth.py +0 -0
- {codicent_cli-0.8.2 → codicent_cli-0.8.4}/codicent_cli.egg-info/SOURCES.txt +0 -0
- {codicent_cli-0.8.2 → codicent_cli-0.8.4}/codicent_cli.egg-info/dependency_links.txt +0 -0
- {codicent_cli-0.8.2 → codicent_cli-0.8.4}/codicent_cli.egg-info/entry_points.txt +0 -0
- {codicent_cli-0.8.2 → codicent_cli-0.8.4}/codicent_cli.egg-info/requires.txt +0 -0
- {codicent_cli-0.8.2 → codicent_cli-0.8.4}/codicent_cli.egg-info/top_level.txt +0 -0
- {codicent_cli-0.8.2 → codicent_cli-0.8.4}/setup.cfg +0 -0
- {codicent_cli-0.8.2 → codicent_cli-0.8.4}/templates/__init__.py +0 -0
- {codicent_cli-0.8.2 → codicent_cli-0.8.4}/templates/default.json +0 -0
- {codicent_cli-0.8.2 → codicent_cli-0.8.4}/templates/document-processing.json +0 -0
- {codicent_cli-0.8.2 → codicent_cli-0.8.4}/templates/koi-studio.json +0 -0
- {codicent_cli-0.8.2 → codicent_cli-0.8.4}/wrap.py +0 -0
|
@@ -132,10 +132,28 @@ EXAMPLES:
|
|
|
132
132
|
codicent init --template koi-studio
|
|
133
133
|
codicent wrap claude
|
|
134
134
|
codicent terminal
|
|
135
|
+
codicent daemon
|
|
135
136
|
codicent update
|
|
136
137
|
codicent update --install
|
|
137
138
|
echo "Help me debug this" | codicent
|
|
138
139
|
|
|
140
|
+
DAEMON (Remote AI Outpost):
|
|
141
|
+
daemon Run as a long-lived background agent that polls for and
|
|
142
|
+
executes delegated commands from the AI (execute_command,
|
|
143
|
+
fs_read, fs_write, fs_list, fs_search). Use this to turn a
|
|
144
|
+
CLI instance into a persistent remote outpost for maintenance.
|
|
145
|
+
|
|
146
|
+
The daemon watches the Codicent project for function_result messages containing
|
|
147
|
+
delegation markers and executes them on behalf of the AI. Session identity ensures
|
|
148
|
+
only commands posted by THIS daemon instance are picked up when multiple daemons
|
|
149
|
+
run against the same project.
|
|
150
|
+
|
|
151
|
+
Config (~/.codicent/wrap-config.yaml):
|
|
152
|
+
autopilot_level: 0 # 0=always confirm, 1=confirm risky, 2=fully autonomous
|
|
153
|
+
allowed_paths: # Restrict fs_* tool access (empty = all paths allowed)
|
|
154
|
+
- /var/log
|
|
155
|
+
- /home/user/project
|
|
156
|
+
|
|
139
157
|
UPDATE:
|
|
140
158
|
update Check if a newer version is available on PyPI
|
|
141
159
|
--install Also upgrade immediately via pip
|
|
@@ -147,7 +165,7 @@ For more information, visit: https://github.com/izaxon/codicent-cli
|
|
|
147
165
|
"""
|
|
148
166
|
print(help_text.strip())
|
|
149
167
|
|
|
150
|
-
CURRENT_VERSION = "0.8.
|
|
168
|
+
CURRENT_VERSION = "0.8.4"
|
|
151
169
|
|
|
152
170
|
def show_version():
|
|
153
171
|
"""Display version information."""
|
|
@@ -163,6 +181,312 @@ def validate_input(question):
|
|
|
163
181
|
|
|
164
182
|
return True, None
|
|
165
183
|
|
|
184
|
+
|
|
185
|
+
# ---------------------------------------------------------------------------
|
|
186
|
+
# CLI config (shares ~/.codicent/wrap-config.yaml with the wrap command)
|
|
187
|
+
# ---------------------------------------------------------------------------
|
|
188
|
+
CLI_CONFIG_PATH = os.path.join(os.path.expanduser("~"), ".codicent", "wrap-config.yaml")
|
|
189
|
+
|
|
190
|
+
def load_cli_config():
|
|
191
|
+
"""Load CLI config from ~/.codicent/wrap-config.yaml."""
|
|
192
|
+
config = {"autopilot_level": 0, "allowed_paths": []}
|
|
193
|
+
if os.path.exists(CLI_CONFIG_PATH):
|
|
194
|
+
try:
|
|
195
|
+
import yaml
|
|
196
|
+
with open(CLI_CONFIG_PATH, "r") as f:
|
|
197
|
+
user_config = yaml.safe_load(f) or {}
|
|
198
|
+
config.update(user_config)
|
|
199
|
+
except Exception as e:
|
|
200
|
+
logger.warning(f"Could not load CLI config: {e}")
|
|
201
|
+
return config
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
# ---------------------------------------------------------------------------
|
|
205
|
+
# Delegation helpers
|
|
206
|
+
# ---------------------------------------------------------------------------
|
|
207
|
+
DELEGATION_PREFIXES = (
|
|
208
|
+
"#execute_command\n",
|
|
209
|
+
"#fs_read\n",
|
|
210
|
+
"#fs_write\n",
|
|
211
|
+
"#fs_list\n",
|
|
212
|
+
"#fs_search\n",
|
|
213
|
+
)
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def _check_allowed_path(path, allowed_paths):
|
|
217
|
+
"""Return True if path is within any of the allowed_paths (or if list is empty)."""
|
|
218
|
+
if not allowed_paths:
|
|
219
|
+
return True
|
|
220
|
+
import pathlib
|
|
221
|
+
try:
|
|
222
|
+
target = pathlib.Path(path).resolve()
|
|
223
|
+
except Exception:
|
|
224
|
+
return False
|
|
225
|
+
for allowed in allowed_paths:
|
|
226
|
+
try:
|
|
227
|
+
allowed_abs = pathlib.Path(allowed).resolve()
|
|
228
|
+
target.relative_to(allowed_abs)
|
|
229
|
+
return True
|
|
230
|
+
except ValueError:
|
|
231
|
+
continue
|
|
232
|
+
return False
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def _parse_delegation(function_result_content):
|
|
236
|
+
"""
|
|
237
|
+
Parse a raw #function_result message content and return
|
|
238
|
+
(function_name, delegation_prefix, data_dict, tool_id) if it is a delegation,
|
|
239
|
+
or None otherwise.
|
|
240
|
+
|
|
241
|
+
function_result_content: the full message content string from GetMessageHistory,
|
|
242
|
+
e.g. '@project\\n#function_result\\n{"role":"function","name":"execute_command","content":"#execute_command\\n{...}","id":"..."}'
|
|
243
|
+
"""
|
|
244
|
+
# Strip @mention and leading tags
|
|
245
|
+
body = re.sub(r"^@\S+\s*", "", function_result_content).strip()
|
|
246
|
+
body = re.sub(r"^#function_result\s*\n?", "", body).strip()
|
|
247
|
+
try:
|
|
248
|
+
fr = json.loads(body)
|
|
249
|
+
except Exception:
|
|
250
|
+
return None
|
|
251
|
+
fc = fr.get("content", "")
|
|
252
|
+
for prefix in DELEGATION_PREFIXES:
|
|
253
|
+
if fc.startswith(prefix):
|
|
254
|
+
try:
|
|
255
|
+
data = json.loads(fc[len(prefix):])
|
|
256
|
+
except Exception:
|
|
257
|
+
return None
|
|
258
|
+
return fr.get("name", ""), prefix.rstrip("\n").lstrip("#"), data, fr.get("id", "")
|
|
259
|
+
return None
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def _execute_fs_delegation(function_name, data, allowed_paths):
|
|
263
|
+
"""Execute a filesystem delegation and return a result string."""
|
|
264
|
+
import pathlib
|
|
265
|
+
import fnmatch
|
|
266
|
+
|
|
267
|
+
if function_name == "fs_read":
|
|
268
|
+
path = data.get("path", "")
|
|
269
|
+
if not _check_allowed_path(path, allowed_paths):
|
|
270
|
+
return f"Error: path '{path}' is not within allowed_paths"
|
|
271
|
+
try:
|
|
272
|
+
with open(path, "r", encoding="utf-8", errors="replace") as f:
|
|
273
|
+
content = f.read()
|
|
274
|
+
return content
|
|
275
|
+
except Exception as e:
|
|
276
|
+
return f"Error reading file: {e}"
|
|
277
|
+
|
|
278
|
+
elif function_name == "fs_write":
|
|
279
|
+
path = data.get("path", "")
|
|
280
|
+
content = data.get("content", "")
|
|
281
|
+
if not _check_allowed_path(path, allowed_paths):
|
|
282
|
+
return f"Error: path '{path}' is not within allowed_paths"
|
|
283
|
+
try:
|
|
284
|
+
os.makedirs(os.path.dirname(os.path.abspath(path)), exist_ok=True)
|
|
285
|
+
with open(path, "w", encoding="utf-8") as f:
|
|
286
|
+
f.write(content)
|
|
287
|
+
return f"Written {len(content)} characters to '{path}'"
|
|
288
|
+
except Exception as e:
|
|
289
|
+
return f"Error writing file: {e}"
|
|
290
|
+
|
|
291
|
+
elif function_name == "fs_list":
|
|
292
|
+
path = data.get("path", "")
|
|
293
|
+
if not _check_allowed_path(path, allowed_paths):
|
|
294
|
+
return f"Error: path '{path}' is not within allowed_paths"
|
|
295
|
+
try:
|
|
296
|
+
entries = os.listdir(path)
|
|
297
|
+
lines = []
|
|
298
|
+
for entry in sorted(entries):
|
|
299
|
+
full = os.path.join(path, entry)
|
|
300
|
+
suffix = "/" if os.path.isdir(full) else ""
|
|
301
|
+
lines.append(entry + suffix)
|
|
302
|
+
return "\n".join(lines) if lines else "(empty)"
|
|
303
|
+
except Exception as e:
|
|
304
|
+
return f"Error listing directory: {e}"
|
|
305
|
+
|
|
306
|
+
elif function_name == "fs_search":
|
|
307
|
+
base = data.get("path", "")
|
|
308
|
+
pattern = data.get("pattern", "*")
|
|
309
|
+
query = data.get("query")
|
|
310
|
+
if not _check_allowed_path(base, allowed_paths):
|
|
311
|
+
return f"Error: path '{base}' is not within allowed_paths"
|
|
312
|
+
try:
|
|
313
|
+
matched = []
|
|
314
|
+
for root, dirs, files in os.walk(base):
|
|
315
|
+
# Prune hidden dirs
|
|
316
|
+
dirs[:] = [d for d in dirs if not d.startswith(".")]
|
|
317
|
+
for fname in files:
|
|
318
|
+
if fnmatch.fnmatch(fname, pattern):
|
|
319
|
+
fpath = os.path.join(root, fname)
|
|
320
|
+
if query:
|
|
321
|
+
try:
|
|
322
|
+
with open(fpath, "r", encoding="utf-8", errors="replace") as f:
|
|
323
|
+
if query.lower() in f.read().lower():
|
|
324
|
+
matched.append(fpath)
|
|
325
|
+
except Exception:
|
|
326
|
+
pass
|
|
327
|
+
else:
|
|
328
|
+
matched.append(fpath)
|
|
329
|
+
return "\n".join(matched[:500]) if matched else "(no matches)"
|
|
330
|
+
except Exception as e:
|
|
331
|
+
return f"Error searching: {e}"
|
|
332
|
+
|
|
333
|
+
return f"Error: unknown fs function '{function_name}'"
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
def _execute_shell_delegation(data, allowed_paths, autopilot_level, console, interactive, require_confirm_fn=None):
|
|
337
|
+
"""
|
|
338
|
+
Execute a shell command delegation.
|
|
339
|
+
Returns (output_str, success_bool).
|
|
340
|
+
require_confirm_fn: callable(cmd) -> bool, called when autopilot_level < 2.
|
|
341
|
+
If None, always executes (daemon mode trusts autopilot_level).
|
|
342
|
+
"""
|
|
343
|
+
cmd = data.get("command", "")
|
|
344
|
+
working_dir = data.get("working_directory")
|
|
345
|
+
timeout = data.get("timeout_seconds", 30)
|
|
346
|
+
|
|
347
|
+
if not cmd:
|
|
348
|
+
return "Error: empty command", False
|
|
349
|
+
|
|
350
|
+
# Autopilot gate
|
|
351
|
+
if require_confirm_fn is not None and autopilot_level < 2:
|
|
352
|
+
if not require_confirm_fn(cmd):
|
|
353
|
+
return "Command skipped by user.", False
|
|
354
|
+
|
|
355
|
+
try:
|
|
356
|
+
result = subprocess.run(
|
|
357
|
+
cmd,
|
|
358
|
+
shell=True,
|
|
359
|
+
capture_output=True,
|
|
360
|
+
text=True,
|
|
361
|
+
timeout=timeout,
|
|
362
|
+
cwd=working_dir,
|
|
363
|
+
encoding="utf-8",
|
|
364
|
+
errors="replace",
|
|
365
|
+
)
|
|
366
|
+
parts = []
|
|
367
|
+
if result.stdout:
|
|
368
|
+
parts.append(result.stdout.strip())
|
|
369
|
+
if result.stderr:
|
|
370
|
+
parts.append(f"stderr: {result.stderr.strip()}")
|
|
371
|
+
parts.append(f"exit_code: {result.returncode}")
|
|
372
|
+
return "\n".join(parts), result.returncode == 0
|
|
373
|
+
except subprocess.TimeoutExpired:
|
|
374
|
+
return f"Error: command timed out after {timeout}s", False
|
|
375
|
+
except Exception as e:
|
|
376
|
+
return f"Error: {e}", False
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
def _post_function_result_msg(base_url, token, verify_https, project, parent_id, function_name, result_content, tool_id):
|
|
380
|
+
"""Post a #function_result message as a child of parent_id (the delegation message)."""
|
|
381
|
+
fr = {"role": "function", "name": function_name, "content": result_content, "id": tool_id or ""}
|
|
382
|
+
body = f"@{project}\n#function_result\n{json.dumps(fr)}"
|
|
383
|
+
url = f"{base_url}app/AddChatMessage"
|
|
384
|
+
payload = {"content": body, "type": "ai-chat-answer2", "parentId": str(parent_id)}
|
|
385
|
+
try:
|
|
386
|
+
resp = requests.post(
|
|
387
|
+
url,
|
|
388
|
+
json=payload,
|
|
389
|
+
headers={"Authorization": f"Bearer {token}"},
|
|
390
|
+
verify=verify_https,
|
|
391
|
+
timeout=30,
|
|
392
|
+
)
|
|
393
|
+
resp.raise_for_status()
|
|
394
|
+
return resp.json()
|
|
395
|
+
except Exception as e:
|
|
396
|
+
logger.error(f"Failed to post function result: {e}")
|
|
397
|
+
return None
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
def _start_chat_async(base_url, token, verify_https, project, message, conversation_id=None):
|
|
401
|
+
"""Post chat message via StartAi2ChatAsync and return the promptMessageId."""
|
|
402
|
+
payload = {"message": message, "project": project}
|
|
403
|
+
if conversation_id:
|
|
404
|
+
payload["messageId"] = str(conversation_id)
|
|
405
|
+
url = f"{base_url}app/StartAi2ChatAsync"
|
|
406
|
+
resp = requests.post(
|
|
407
|
+
url,
|
|
408
|
+
json=payload,
|
|
409
|
+
headers={"Authorization": f"Bearer {token}"},
|
|
410
|
+
verify=verify_https,
|
|
411
|
+
timeout=30,
|
|
412
|
+
)
|
|
413
|
+
resp.raise_for_status()
|
|
414
|
+
return resp.json().get("promptMessageId")
|
|
415
|
+
|
|
416
|
+
|
|
417
|
+
def _fetch_history(base_url, token, verify_https, message_id):
|
|
418
|
+
"""Fetch conversation history chain for the given message_id."""
|
|
419
|
+
url = f"{base_url}app/GetMessageHistory"
|
|
420
|
+
resp = requests.get(
|
|
421
|
+
url,
|
|
422
|
+
params={"messageId": str(message_id)},
|
|
423
|
+
headers={"Authorization": f"Bearer {token}"},
|
|
424
|
+
verify=verify_https,
|
|
425
|
+
timeout=15,
|
|
426
|
+
)
|
|
427
|
+
resp.raise_for_status()
|
|
428
|
+
return resp.json() if resp.text else []
|
|
429
|
+
|
|
430
|
+
|
|
431
|
+
def _wait_for_ai_reply_with_delegation(
|
|
432
|
+
base_url, token, verify_https, project, prompt_message_id,
|
|
433
|
+
on_delegation, poll_interval=1.5, timeout=360
|
|
434
|
+
):
|
|
435
|
+
"""
|
|
436
|
+
Poll GetMessageHistory until a final text reply is found.
|
|
437
|
+
Calls on_delegation(msg_content, msg_id) when a delegation is detected.
|
|
438
|
+
Returns {id, content} of the final text message, or None on timeout.
|
|
439
|
+
"""
|
|
440
|
+
import time
|
|
441
|
+
deadline = time.time() + timeout
|
|
442
|
+
last_seen_id = None
|
|
443
|
+
|
|
444
|
+
while time.time() < deadline:
|
|
445
|
+
try:
|
|
446
|
+
history = _fetch_history(base_url, token, verify_https, prompt_message_id)
|
|
447
|
+
except Exception as e:
|
|
448
|
+
logger.warning(f"History fetch error: {e}")
|
|
449
|
+
time.sleep(poll_interval)
|
|
450
|
+
continue
|
|
451
|
+
|
|
452
|
+
if not history or len(history) < 2:
|
|
453
|
+
time.sleep(poll_interval)
|
|
454
|
+
continue
|
|
455
|
+
|
|
456
|
+
# Latest message in the chain (leaf)
|
|
457
|
+
last = history[-1]
|
|
458
|
+
last_id = last.get("id") or last.get("Id")
|
|
459
|
+
content = last.get("content") or last.get("Content", "")
|
|
460
|
+
|
|
461
|
+
if last_id == last_seen_id:
|
|
462
|
+
time.sleep(poll_interval)
|
|
463
|
+
continue
|
|
464
|
+
last_seen_id = last_id
|
|
465
|
+
|
|
466
|
+
# Check for delegation in function_result messages
|
|
467
|
+
if "#function_result" in content:
|
|
468
|
+
delegation = _parse_delegation(content)
|
|
469
|
+
if delegation:
|
|
470
|
+
on_delegation(content, last_id)
|
|
471
|
+
time.sleep(poll_interval)
|
|
472
|
+
continue
|
|
473
|
+
|
|
474
|
+
# Check if this is an intermediate (non-finished) engine state
|
|
475
|
+
intermediate_markers = [
|
|
476
|
+
"#chat2", "#finish_reason_function_call", "#finish_reason_tool_calls_batch",
|
|
477
|
+
"#function_result", "#function_results_batch",
|
|
478
|
+
]
|
|
479
|
+
if any(m in content for m in intermediate_markers):
|
|
480
|
+
time.sleep(poll_interval)
|
|
481
|
+
continue
|
|
482
|
+
|
|
483
|
+
# Finished: strip @mention and leading tags for display
|
|
484
|
+
clean = re.sub(r"^@\S+\s*", "", content).strip()
|
|
485
|
+
clean = re.sub(r"^(#\S+\s*)+", "", clean).strip()
|
|
486
|
+
return {"id": last_id, "content": clean}
|
|
487
|
+
|
|
488
|
+
return None # timeout
|
|
489
|
+
|
|
166
490
|
def main():
|
|
167
491
|
console = Console()
|
|
168
492
|
|
|
@@ -542,6 +866,124 @@ def main():
|
|
|
542
866
|
return 0 if failed == 0 else 1
|
|
543
867
|
|
|
544
868
|
# Handle wrap command
|
|
869
|
+
# -----------------------------------------------------------------------
|
|
870
|
+
# daemon subcommand — persistent remote AI outpost
|
|
871
|
+
# -----------------------------------------------------------------------
|
|
872
|
+
if len(sys.argv) > 1 and sys.argv[1] == "daemon":
|
|
873
|
+
import time
|
|
874
|
+
import uuid
|
|
875
|
+
|
|
876
|
+
daemon_token = auth.get_cached_token()
|
|
877
|
+
if not daemon_token:
|
|
878
|
+
daemon_token = os.getenv("CODICENT_TOKEN")
|
|
879
|
+
if not daemon_token:
|
|
880
|
+
console.print("[red]❌ No authentication found.[/red]")
|
|
881
|
+
console.print("[dim]Run 'codicent auth' to authenticate, or set CODICENT_TOKEN[/dim]")
|
|
882
|
+
return 1
|
|
883
|
+
|
|
884
|
+
try:
|
|
885
|
+
dp = daemon_token.split(".")[1]
|
|
886
|
+
dp += '=' * (-len(dp) % 4)
|
|
887
|
+
daemon_jwt = json.loads(base64.urlsafe_b64decode(dp).decode('utf-8'))
|
|
888
|
+
daemon_project = daemon_jwt["project"]
|
|
889
|
+
daemon_base_url = daemon_jwt.get("baseUrl", "https://codicent.com/")
|
|
890
|
+
daemon_verify = True
|
|
891
|
+
except Exception as e:
|
|
892
|
+
console.print(f"[red]❌ Failed to extract project from token: {e}[/red]")
|
|
893
|
+
return 1
|
|
894
|
+
|
|
895
|
+
daemon_config = load_cli_config()
|
|
896
|
+
daemon_autopilot = daemon_config.get("autopilot_level", 0)
|
|
897
|
+
daemon_allowed = daemon_config.get("allowed_paths", [])
|
|
898
|
+
daemon_poll_interval = daemon_config.get("daemon", {}).get("poll_interval", 5)
|
|
899
|
+
|
|
900
|
+
# Stable session identity — persistent across restarts when using the same token
|
|
901
|
+
session_id = daemon_jwt.get("sub", "")[:16] or str(uuid.uuid4())[:8]
|
|
902
|
+
console.print(f"[bold green]🤖 Codicent Daemon started[/bold green] (session={session_id})")
|
|
903
|
+
console.print(f"[dim]Project: {daemon_project} | Autopilot level: {daemon_autopilot} | Poll: {daemon_poll_interval}s[/dim]")
|
|
904
|
+
if daemon_allowed:
|
|
905
|
+
console.print(f"[dim]Allowed paths: {daemon_allowed}[/dim]")
|
|
906
|
+
|
|
907
|
+
# Track delegation messages we have posted results for (avoid double-execution)
|
|
908
|
+
executed_ids = set()
|
|
909
|
+
|
|
910
|
+
# We watch for delegation markers in "pending" messages addressed via session_id
|
|
911
|
+
# The engine embeds session_id in every delegation JSON if provided in the tool call.
|
|
912
|
+
# Format: @project #function_result {"role":"function","name":"...","content":"#execute_command\n{...session_id:...}","id":"..."}
|
|
913
|
+
|
|
914
|
+
def _fetch_pending_delegations():
|
|
915
|
+
"""Fetch unhandled (leaf) function_result messages for this project."""
|
|
916
|
+
url = f"{daemon_base_url}app/GetPendingDelegations"
|
|
917
|
+
try:
|
|
918
|
+
resp = requests.get(
|
|
919
|
+
url,
|
|
920
|
+
params={"project": daemon_project, "maxMessages": 50},
|
|
921
|
+
headers={"Authorization": f"Bearer {daemon_token}"},
|
|
922
|
+
verify=daemon_verify,
|
|
923
|
+
timeout=15,
|
|
924
|
+
)
|
|
925
|
+
resp.raise_for_status()
|
|
926
|
+
return resp.json() if resp.text else []
|
|
927
|
+
except Exception as e:
|
|
928
|
+
logger.debug(f"Daemon poll error: {e}")
|
|
929
|
+
return []
|
|
930
|
+
|
|
931
|
+
try:
|
|
932
|
+
while True:
|
|
933
|
+
msgs = _fetch_pending_delegations()
|
|
934
|
+
for msg in msgs:
|
|
935
|
+
msg_id = msg.get("id") or msg.get("Id")
|
|
936
|
+
if not msg_id or msg_id in executed_ids:
|
|
937
|
+
continue
|
|
938
|
+
content = msg.get("content") or msg.get("Content", "")
|
|
939
|
+
if "#function_result" not in content:
|
|
940
|
+
continue
|
|
941
|
+
|
|
942
|
+
delegation = _parse_delegation(content)
|
|
943
|
+
if not delegation:
|
|
944
|
+
continue
|
|
945
|
+
|
|
946
|
+
func_name, op, data, tool_id = delegation
|
|
947
|
+
|
|
948
|
+
# Filter by session_id if present in the delegation data
|
|
949
|
+
msg_session = data.get("session_id", "")
|
|
950
|
+
if msg_session and msg_session != session_id:
|
|
951
|
+
continue
|
|
952
|
+
|
|
953
|
+
# Mark as handled immediately to avoid duplicate execution
|
|
954
|
+
executed_ids.add(msg_id)
|
|
955
|
+
|
|
956
|
+
console.print(f"\n[bold cyan]→ Delegation:[/bold cyan] {op} | msg_id={msg_id}")
|
|
957
|
+
|
|
958
|
+
if op == "execute_command":
|
|
959
|
+
cmd = data.get("command", "")
|
|
960
|
+
console.print(f" cmd: {cmd}")
|
|
961
|
+
if daemon_autopilot < 1:
|
|
962
|
+
# Non-interactive: always require autopilot_level >= 1 for daemon
|
|
963
|
+
result_str = "Daemon requires autopilot_level >= 1 for execute_command. Set in ~/.codicent/wrap-config.yaml."
|
|
964
|
+
else:
|
|
965
|
+
result_str, _ = _execute_shell_delegation(
|
|
966
|
+
data, daemon_allowed, daemon_autopilot, console, False, None
|
|
967
|
+
)
|
|
968
|
+
else:
|
|
969
|
+
result_str = _execute_fs_delegation(op, data, daemon_allowed)
|
|
970
|
+
|
|
971
|
+
console.print(f" result: {result_str[:160]}{'…' if len(result_str) > 160 else ''}")
|
|
972
|
+
_post_function_result_msg(
|
|
973
|
+
daemon_base_url, daemon_token, daemon_verify,
|
|
974
|
+
daemon_project, msg_id, func_name, result_str, tool_id
|
|
975
|
+
)
|
|
976
|
+
|
|
977
|
+
# Trim executed_ids if it gets large
|
|
978
|
+
if len(executed_ids) > 2000:
|
|
979
|
+
executed_ids = set(list(executed_ids)[-1000:])
|
|
980
|
+
|
|
981
|
+
time.sleep(daemon_poll_interval)
|
|
982
|
+
|
|
983
|
+
except KeyboardInterrupt:
|
|
984
|
+
console.print("\n[yellow]Daemon stopped.[/yellow]")
|
|
985
|
+
return 0
|
|
986
|
+
|
|
545
987
|
if len(sys.argv) > 1 and sys.argv[1] == "wrap":
|
|
546
988
|
from wrap import run_wrap_command
|
|
547
989
|
|
|
@@ -627,6 +1069,9 @@ def main():
|
|
|
627
1069
|
return 1
|
|
628
1070
|
|
|
629
1071
|
conversationId = None
|
|
1072
|
+
cli_config = load_cli_config()
|
|
1073
|
+
autopilot_level = cli_config.get("autopilot_level", 0)
|
|
1074
|
+
allowed_paths = cli_config.get("allowed_paths", [])
|
|
630
1075
|
|
|
631
1076
|
# Parse interactive mode flags
|
|
632
1077
|
interactive = False
|
|
@@ -682,30 +1127,92 @@ def main():
|
|
|
682
1127
|
console.print("[green]✅ Message posted successfully.[/green]")
|
|
683
1128
|
else:
|
|
684
1129
|
logger.info("Sending chat reply to Codicent API")
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
1130
|
+
|
|
1131
|
+
# --- Async delegation loop ---
|
|
1132
|
+
def handle_delegation(msg_content, msg_id):
|
|
1133
|
+
"""Called when a delegation message is found during inline wait."""
|
|
1134
|
+
delegation = _parse_delegation(msg_content)
|
|
1135
|
+
if not delegation:
|
|
1136
|
+
return
|
|
1137
|
+
func_name, op, data, tool_id = delegation
|
|
1138
|
+
|
|
1139
|
+
if op == "execute_command":
|
|
1140
|
+
cmd = data.get("command", "")
|
|
1141
|
+
console.print(f"\n[bold yellow]⚡ AI wants to run:[/bold yellow] {cmd}")
|
|
1142
|
+
|
|
1143
|
+
def confirm_fn(c):
|
|
1144
|
+
if autopilot_level >= 2:
|
|
1145
|
+
return True
|
|
1146
|
+
if not interactive:
|
|
1147
|
+
return autopilot_level >= 1
|
|
1148
|
+
try:
|
|
1149
|
+
ans = prompt("Allow? (y/N) ")
|
|
1150
|
+
return ans.strip().lower() == "y"
|
|
1151
|
+
except (KeyboardInterrupt, EOFError):
|
|
1152
|
+
return False
|
|
1153
|
+
|
|
1154
|
+
with console.status("[dim]Running command...[/dim]", spinner="dots"):
|
|
1155
|
+
result_str, _ = _execute_shell_delegation(
|
|
1156
|
+
data, allowed_paths, autopilot_level, console, interactive, confirm_fn
|
|
1157
|
+
)
|
|
1158
|
+
console.print(f"[dim]Result: {result_str[:200]}{'…' if len(result_str) > 200 else ''}[/dim]")
|
|
1159
|
+
_post_function_result_msg(
|
|
1160
|
+
codicent.base_url, token, codicent.verify_https,
|
|
1161
|
+
project, msg_id, func_name, result_str, tool_id
|
|
1162
|
+
)
|
|
1163
|
+
|
|
1164
|
+
elif op in ("fs_read", "fs_write", "fs_list", "fs_search"):
|
|
1165
|
+
path = data.get("path", "")
|
|
1166
|
+
console.print(f"\n[bold yellow]📂 AI filesystem op:[/bold yellow] {op} {path}")
|
|
1167
|
+
if not _check_allowed_path(path, allowed_paths):
|
|
1168
|
+
result_str = f"Error: path '{path}' is not within allowed_paths"
|
|
1169
|
+
else:
|
|
1170
|
+
with console.status("[dim]Executing...[/dim]", spinner="dots"):
|
|
1171
|
+
result_str = _execute_fs_delegation(op, data, allowed_paths)
|
|
1172
|
+
console.print(f"[dim]{result_str[:200]}{'…' if len(result_str) > 200 else ''}[/dim]")
|
|
1173
|
+
_post_function_result_msg(
|
|
1174
|
+
codicent.base_url, token, codicent.verify_https,
|
|
1175
|
+
project, msg_id, func_name, result_str, tool_id
|
|
1176
|
+
)
|
|
1177
|
+
|
|
1178
|
+
try:
|
|
1179
|
+
prompt_id = _start_chat_async(
|
|
1180
|
+
codicent.base_url, token, codicent.verify_https,
|
|
1181
|
+
project, question, conversationId
|
|
1182
|
+
)
|
|
1183
|
+
except Exception:
|
|
1184
|
+
# Fallback to synchronous post_chat_reply (no delegation support)
|
|
1185
|
+
with console.status("[dim]🤔 Thinking...[/dim]", spinner="dots"):
|
|
1186
|
+
response = codicent.post_chat_reply(question, conversationId)
|
|
1187
|
+
conversationId = response["id"]
|
|
1188
|
+
content = response["content"]
|
|
1189
|
+
else:
|
|
1190
|
+
with console.status("[dim]🤔 Thinking...[/dim]", spinner="dots"):
|
|
1191
|
+
response = _wait_for_ai_reply_with_delegation(
|
|
1192
|
+
codicent.base_url, token, codicent.verify_https,
|
|
1193
|
+
project, prompt_id, handle_delegation
|
|
1194
|
+
)
|
|
1195
|
+
if response is None:
|
|
1196
|
+
console.print("[red]⏱ Request timed out or no reply received.[/red]")
|
|
1197
|
+
return False
|
|
1198
|
+
conversationId = response["id"]
|
|
1199
|
+
content = response["content"]
|
|
1200
|
+
|
|
688
1201
|
logger.info(f"Updated conversation ID: {conversationId}")
|
|
689
|
-
|
|
1202
|
+
|
|
690
1203
|
# Show bot response with markdown formatting in green
|
|
691
1204
|
if interactive:
|
|
692
1205
|
console.print()
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
# Detect shell: markers in AI response
|
|
1206
|
+
|
|
1207
|
+
# Backward-compat: detect legacy shell: markers in AI response
|
|
697
1208
|
shell_commands = re.findall(r'^shell:(.+)$', content, re.MULTILINE)
|
|
698
|
-
|
|
699
|
-
# Also detect ```shell code blocks
|
|
700
1209
|
shell_blocks = re.findall(r'```shell\n(.*?)```', content, re.DOTALL)
|
|
701
|
-
|
|
702
|
-
# Create markdown with green styling
|
|
1210
|
+
|
|
703
1211
|
from rich.text import Text
|
|
704
1212
|
markdown_content = Markdown(content)
|
|
705
1213
|
console.print(markdown_content, style="green")
|
|
706
1214
|
console.print()
|
|
707
|
-
|
|
708
|
-
# Offer to run detected shell commands
|
|
1215
|
+
|
|
709
1216
|
all_commands = shell_commands + [block.strip() for block in shell_blocks]
|
|
710
1217
|
for cmd in all_commands:
|
|
711
1218
|
cmd = cmd.strip()
|
|
@@ -720,7 +1227,6 @@ def main():
|
|
|
720
1227
|
if confirm.strip().lower() == 'y':
|
|
721
1228
|
console.print(f"[dim]Running: {cmd}[/dim]")
|
|
722
1229
|
try:
|
|
723
|
-
# Run command with proper encoding handling for cross-platform compatibility
|
|
724
1230
|
result = subprocess.run(
|
|
725
1231
|
cmd,
|
|
726
1232
|
shell=True,
|
|
@@ -728,10 +1234,9 @@ def main():
|
|
|
728
1234
|
text=True,
|
|
729
1235
|
timeout=60,
|
|
730
1236
|
encoding='utf-8',
|
|
731
|
-
errors='replace'
|
|
1237
|
+
errors='replace'
|
|
732
1238
|
)
|
|
733
1239
|
|
|
734
|
-
# Display output to user
|
|
735
1240
|
if result.stdout:
|
|
736
1241
|
console.print(result.stdout)
|
|
737
1242
|
if result.stderr:
|
|
@@ -741,57 +1246,37 @@ def main():
|
|
|
741
1246
|
else:
|
|
742
1247
|
console.print(f"[red]Exit code: {result.returncode}[/red]")
|
|
743
1248
|
|
|
744
|
-
|
|
745
|
-
output_parts = []
|
|
746
|
-
output_parts.append(f"Command executed: `{cmd}`")
|
|
747
|
-
output_parts.append("")
|
|
1249
|
+
output_parts = [f"Command executed: `{cmd}`", ""]
|
|
748
1250
|
if result.stdout:
|
|
749
|
-
output_parts
|
|
750
|
-
output_parts.append("```")
|
|
751
|
-
output_parts.append(result.stdout.strip())
|
|
752
|
-
output_parts.append("```")
|
|
1251
|
+
output_parts += ["Output:", "```", result.stdout.strip(), "```"]
|
|
753
1252
|
if result.stderr:
|
|
754
|
-
output_parts
|
|
755
|
-
output_parts.append("```")
|
|
756
|
-
output_parts.append(result.stderr.strip())
|
|
757
|
-
output_parts.append("```")
|
|
1253
|
+
output_parts += ["Errors:", "```", result.stderr.strip(), "```"]
|
|
758
1254
|
output_parts.append(f"Exit code: {result.returncode}")
|
|
759
|
-
|
|
760
1255
|
command_output = "\n".join(output_parts)
|
|
761
1256
|
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
1257
|
+
try:
|
|
1258
|
+
prompt_id2 = _start_chat_async(
|
|
1259
|
+
codicent.base_url, token, codicent.verify_https,
|
|
1260
|
+
project, command_output, conversationId
|
|
1261
|
+
)
|
|
1262
|
+
with console.status("[dim]Sending output to AI...[/dim]", spinner="dots"):
|
|
1263
|
+
resp2 = _wait_for_ai_reply_with_delegation(
|
|
1264
|
+
codicent.base_url, token, codicent.verify_https,
|
|
1265
|
+
project, prompt_id2, handle_delegation
|
|
1266
|
+
)
|
|
1267
|
+
if resp2:
|
|
1268
|
+
conversationId = resp2["id"]
|
|
1269
|
+
console.print()
|
|
1270
|
+
console.print("[dim]AI response:[/dim]")
|
|
1271
|
+
console.print(Markdown(resp2["content"]), style="green")
|
|
1272
|
+
console.print()
|
|
1273
|
+
except Exception as e:
|
|
1274
|
+
logger.warning(f"Could not send shell output back to AI: {e}")
|
|
774
1275
|
|
|
775
1276
|
except subprocess.TimeoutExpired:
|
|
776
|
-
|
|
777
|
-
console.print(f"[red]{error_msg}[/red]")
|
|
778
|
-
# Send timeout info to AI
|
|
779
|
-
try:
|
|
780
|
-
with console.status("[dim]Sending timeout info to AI...[/dim]", spinner="dots"):
|
|
781
|
-
response = codicent.post_chat_reply(f"Command timed out: `{cmd}`", conversationId)
|
|
782
|
-
conversationId = response["id"]
|
|
783
|
-
except Exception:
|
|
784
|
-
pass # Don't fail if we can't send the timeout
|
|
1277
|
+
console.print("[red]Command timed out after 60 seconds[/red]")
|
|
785
1278
|
except Exception as e:
|
|
786
|
-
|
|
787
|
-
console.print(f"[red]{error_msg}[/red]")
|
|
788
|
-
# Send error info to AI
|
|
789
|
-
try:
|
|
790
|
-
with console.status("[dim]Sending error info to AI...[/dim]", spinner="dots"):
|
|
791
|
-
response = codicent.post_chat_reply(f"Command failed: `{cmd}`\nError: {str(e)}", conversationId)
|
|
792
|
-
conversationId = response["id"]
|
|
793
|
-
except Exception:
|
|
794
|
-
pass # Don't fail if we can't send the error
|
|
1279
|
+
console.print(f"[red]Error: {e}[/red]")
|
|
795
1280
|
else:
|
|
796
1281
|
console.print("[dim]Skipped.[/dim]")
|
|
797
1282
|
else:
|
|
@@ -70,7 +70,8 @@ def run_terminal_host(token: str, base_url: str, project: str, shell: str = None
|
|
|
70
70
|
)
|
|
71
71
|
|
|
72
72
|
print(f"[codicent terminal] project=@{project} shell={shell} session={session_id}")
|
|
73
|
-
|
|
73
|
+
ws_display = f"{ws_base}/terminal/host?project={project}&command={os.path.basename(shell)}&sessionId={session_id}"
|
|
74
|
+
print(f"[codicent terminal] Connecting to {ws_display} ...")
|
|
74
75
|
|
|
75
76
|
stop_event = threading.Event()
|
|
76
77
|
ws_app = None
|
|
@@ -101,7 +102,9 @@ def run_terminal_host(token: str, base_url: str, project: str, shell: str = None
|
|
|
101
102
|
_set_pty_size(cols, rows)
|
|
102
103
|
|
|
103
104
|
def on_open(ws):
|
|
104
|
-
|
|
105
|
+
browser_url = f"{base_url.rstrip('/')}/terminal/ws?sessionId={session_id}"
|
|
106
|
+
print(f"[codicent terminal] Connected. Shell is ready in the browser.")
|
|
107
|
+
print(f"[codicent terminal] Browser connect: {browser_url}")
|
|
105
108
|
# Send initial resize
|
|
106
109
|
import json
|
|
107
110
|
ws.send(json.dumps({"type": "resize", "cols": cols, "rows": rows}))
|
|
@@ -199,7 +202,9 @@ def run_terminal_host(token: str, base_url: str, project: str, shell: str = None
|
|
|
199
202
|
threading.Thread(target=_stdout_reader, daemon=True).start()
|
|
200
203
|
|
|
201
204
|
def on_open(ws):
|
|
202
|
-
|
|
205
|
+
browser_url = f"{base_url.rstrip('/')}/terminal/ws?sessionId={session_id}"
|
|
206
|
+
print(f"[codicent terminal] Connected.")
|
|
207
|
+
print(f"[codicent terminal] Browser connect: {browser_url}")
|
|
203
208
|
|
|
204
209
|
# Thread: drain queue -> WebSocket
|
|
205
210
|
def _sender():
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|