codicent-cli 0.8.3__tar.gz → 0.8.5__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.3 → codicent_cli-0.8.5}/PKG-INFO +1 -1
- {codicent_cli-0.8.3 → codicent_cli-0.8.5}/app.py +589 -61
- {codicent_cli-0.8.3 → codicent_cli-0.8.5}/codicent_cli.egg-info/PKG-INFO +1 -1
- {codicent_cli-0.8.3 → codicent_cli-0.8.5}/setup.py +1 -1
- {codicent_cli-0.8.3 → codicent_cli-0.8.5}/terminal.py +2 -2
- {codicent_cli-0.8.3 → codicent_cli-0.8.5}/LICENSE +0 -0
- {codicent_cli-0.8.3 → codicent_cli-0.8.5}/README.md +0 -0
- {codicent_cli-0.8.3 → codicent_cli-0.8.5}/auth.py +0 -0
- {codicent_cli-0.8.3 → codicent_cli-0.8.5}/codicent_cli.egg-info/SOURCES.txt +0 -0
- {codicent_cli-0.8.3 → codicent_cli-0.8.5}/codicent_cli.egg-info/dependency_links.txt +0 -0
- {codicent_cli-0.8.3 → codicent_cli-0.8.5}/codicent_cli.egg-info/entry_points.txt +0 -0
- {codicent_cli-0.8.3 → codicent_cli-0.8.5}/codicent_cli.egg-info/requires.txt +0 -0
- {codicent_cli-0.8.3 → codicent_cli-0.8.5}/codicent_cli.egg-info/top_level.txt +0 -0
- {codicent_cli-0.8.3 → codicent_cli-0.8.5}/setup.cfg +0 -0
- {codicent_cli-0.8.3 → codicent_cli-0.8.5}/templates/__init__.py +0 -0
- {codicent_cli-0.8.3 → codicent_cli-0.8.5}/templates/default.json +0 -0
- {codicent_cli-0.8.3 → codicent_cli-0.8.5}/templates/document-processing.json +0 -0
- {codicent_cli-0.8.3 → codicent_cli-0.8.5}/templates/koi-studio.json +0 -0
- {codicent_cli-0.8.3 → codicent_cli-0.8.5}/wrap.py +0 -0
|
@@ -123,6 +123,23 @@ TERMINAL (Web Terminal Host):
|
|
|
123
123
|
codicent terminal bash
|
|
124
124
|
codicent terminal zsh
|
|
125
125
|
|
|
126
|
+
TUI (Text User Interface):
|
|
127
|
+
tui Open a split-screen terminal UI with a logbook home panel on
|
|
128
|
+
the left (recent messages + compose) and an AI chat panel on
|
|
129
|
+
the right. Uses the same authentication as other commands.
|
|
130
|
+
|
|
131
|
+
Key bindings:
|
|
132
|
+
Tab / Shift+Tab Switch focus between compose and chat inputs
|
|
133
|
+
Enter Send the current input
|
|
134
|
+
Escape + Enter Insert a newline in the active input
|
|
135
|
+
Ctrl+R Refresh message list
|
|
136
|
+
F1 or Alt+1 Focus compose input
|
|
137
|
+
F2 or Alt+2 Focus chat input
|
|
138
|
+
Ctrl+C Quit
|
|
139
|
+
|
|
140
|
+
Example:
|
|
141
|
+
codicent tui
|
|
142
|
+
|
|
126
143
|
EXAMPLES:
|
|
127
144
|
codicent auth
|
|
128
145
|
codicent "What is Python?"
|
|
@@ -132,10 +149,29 @@ EXAMPLES:
|
|
|
132
149
|
codicent init --template koi-studio
|
|
133
150
|
codicent wrap claude
|
|
134
151
|
codicent terminal
|
|
152
|
+
codicent daemon
|
|
153
|
+
codicent tui
|
|
135
154
|
codicent update
|
|
136
155
|
codicent update --install
|
|
137
156
|
echo "Help me debug this" | codicent
|
|
138
157
|
|
|
158
|
+
DAEMON (Remote AI Outpost):
|
|
159
|
+
daemon Run as a long-lived background agent that polls for and
|
|
160
|
+
executes delegated commands from the AI (execute_command,
|
|
161
|
+
fs_read, fs_write, fs_list, fs_search). Use this to turn a
|
|
162
|
+
CLI instance into a persistent remote outpost for maintenance.
|
|
163
|
+
|
|
164
|
+
The daemon watches the Codicent project for function_result messages containing
|
|
165
|
+
delegation markers and executes them on behalf of the AI. Session identity ensures
|
|
166
|
+
only commands posted by THIS daemon instance are picked up when multiple daemons
|
|
167
|
+
run against the same project.
|
|
168
|
+
|
|
169
|
+
Config (~/.codicent/wrap-config.yaml):
|
|
170
|
+
autopilot_level: 0 # 0=always confirm, 1=confirm risky, 2=fully autonomous
|
|
171
|
+
allowed_paths: # Restrict fs_* tool access (empty = all paths allowed)
|
|
172
|
+
- /var/log
|
|
173
|
+
- /home/user/project
|
|
174
|
+
|
|
139
175
|
UPDATE:
|
|
140
176
|
update Check if a newer version is available on PyPI
|
|
141
177
|
--install Also upgrade immediately via pip
|
|
@@ -147,7 +183,7 @@ For more information, visit: https://github.com/izaxon/codicent-cli
|
|
|
147
183
|
"""
|
|
148
184
|
print(help_text.strip())
|
|
149
185
|
|
|
150
|
-
CURRENT_VERSION = "0.8.
|
|
186
|
+
CURRENT_VERSION = "0.8.4"
|
|
151
187
|
|
|
152
188
|
def show_version():
|
|
153
189
|
"""Display version information."""
|
|
@@ -163,6 +199,312 @@ def validate_input(question):
|
|
|
163
199
|
|
|
164
200
|
return True, None
|
|
165
201
|
|
|
202
|
+
|
|
203
|
+
# ---------------------------------------------------------------------------
|
|
204
|
+
# CLI config (shares ~/.codicent/wrap-config.yaml with the wrap command)
|
|
205
|
+
# ---------------------------------------------------------------------------
|
|
206
|
+
CLI_CONFIG_PATH = os.path.join(os.path.expanduser("~"), ".codicent", "wrap-config.yaml")
|
|
207
|
+
|
|
208
|
+
def load_cli_config():
|
|
209
|
+
"""Load CLI config from ~/.codicent/wrap-config.yaml."""
|
|
210
|
+
config = {"autopilot_level": 0, "allowed_paths": []}
|
|
211
|
+
if os.path.exists(CLI_CONFIG_PATH):
|
|
212
|
+
try:
|
|
213
|
+
import yaml
|
|
214
|
+
with open(CLI_CONFIG_PATH, "r") as f:
|
|
215
|
+
user_config = yaml.safe_load(f) or {}
|
|
216
|
+
config.update(user_config)
|
|
217
|
+
except Exception as e:
|
|
218
|
+
logger.warning(f"Could not load CLI config: {e}")
|
|
219
|
+
return config
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
# ---------------------------------------------------------------------------
|
|
223
|
+
# Delegation helpers
|
|
224
|
+
# ---------------------------------------------------------------------------
|
|
225
|
+
DELEGATION_PREFIXES = (
|
|
226
|
+
"#execute_command\n",
|
|
227
|
+
"#fs_read\n",
|
|
228
|
+
"#fs_write\n",
|
|
229
|
+
"#fs_list\n",
|
|
230
|
+
"#fs_search\n",
|
|
231
|
+
)
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def _check_allowed_path(path, allowed_paths):
|
|
235
|
+
"""Return True if path is within any of the allowed_paths (or if list is empty)."""
|
|
236
|
+
if not allowed_paths:
|
|
237
|
+
return True
|
|
238
|
+
import pathlib
|
|
239
|
+
try:
|
|
240
|
+
target = pathlib.Path(path).resolve()
|
|
241
|
+
except Exception:
|
|
242
|
+
return False
|
|
243
|
+
for allowed in allowed_paths:
|
|
244
|
+
try:
|
|
245
|
+
allowed_abs = pathlib.Path(allowed).resolve()
|
|
246
|
+
target.relative_to(allowed_abs)
|
|
247
|
+
return True
|
|
248
|
+
except ValueError:
|
|
249
|
+
continue
|
|
250
|
+
return False
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def _parse_delegation(function_result_content):
|
|
254
|
+
"""
|
|
255
|
+
Parse a raw #function_result message content and return
|
|
256
|
+
(function_name, delegation_prefix, data_dict, tool_id) if it is a delegation,
|
|
257
|
+
or None otherwise.
|
|
258
|
+
|
|
259
|
+
function_result_content: the full message content string from GetMessageHistory,
|
|
260
|
+
e.g. '@project\\n#function_result\\n{"role":"function","name":"execute_command","content":"#execute_command\\n{...}","id":"..."}'
|
|
261
|
+
"""
|
|
262
|
+
# Strip @mention and leading tags
|
|
263
|
+
body = re.sub(r"^@\S+\s*", "", function_result_content).strip()
|
|
264
|
+
body = re.sub(r"^#function_result\s*\n?", "", body).strip()
|
|
265
|
+
try:
|
|
266
|
+
fr = json.loads(body)
|
|
267
|
+
except Exception:
|
|
268
|
+
return None
|
|
269
|
+
fc = fr.get("content", "")
|
|
270
|
+
for prefix in DELEGATION_PREFIXES:
|
|
271
|
+
if fc.startswith(prefix):
|
|
272
|
+
try:
|
|
273
|
+
data = json.loads(fc[len(prefix):])
|
|
274
|
+
except Exception:
|
|
275
|
+
return None
|
|
276
|
+
return fr.get("name", ""), prefix.rstrip("\n").lstrip("#"), data, fr.get("id", "")
|
|
277
|
+
return None
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def _execute_fs_delegation(function_name, data, allowed_paths):
|
|
281
|
+
"""Execute a filesystem delegation and return a result string."""
|
|
282
|
+
import pathlib
|
|
283
|
+
import fnmatch
|
|
284
|
+
|
|
285
|
+
if function_name == "fs_read":
|
|
286
|
+
path = data.get("path", "")
|
|
287
|
+
if not _check_allowed_path(path, allowed_paths):
|
|
288
|
+
return f"Error: path '{path}' is not within allowed_paths"
|
|
289
|
+
try:
|
|
290
|
+
with open(path, "r", encoding="utf-8", errors="replace") as f:
|
|
291
|
+
content = f.read()
|
|
292
|
+
return content
|
|
293
|
+
except Exception as e:
|
|
294
|
+
return f"Error reading file: {e}"
|
|
295
|
+
|
|
296
|
+
elif function_name == "fs_write":
|
|
297
|
+
path = data.get("path", "")
|
|
298
|
+
content = data.get("content", "")
|
|
299
|
+
if not _check_allowed_path(path, allowed_paths):
|
|
300
|
+
return f"Error: path '{path}' is not within allowed_paths"
|
|
301
|
+
try:
|
|
302
|
+
os.makedirs(os.path.dirname(os.path.abspath(path)), exist_ok=True)
|
|
303
|
+
with open(path, "w", encoding="utf-8") as f:
|
|
304
|
+
f.write(content)
|
|
305
|
+
return f"Written {len(content)} characters to '{path}'"
|
|
306
|
+
except Exception as e:
|
|
307
|
+
return f"Error writing file: {e}"
|
|
308
|
+
|
|
309
|
+
elif function_name == "fs_list":
|
|
310
|
+
path = data.get("path", "")
|
|
311
|
+
if not _check_allowed_path(path, allowed_paths):
|
|
312
|
+
return f"Error: path '{path}' is not within allowed_paths"
|
|
313
|
+
try:
|
|
314
|
+
entries = os.listdir(path)
|
|
315
|
+
lines = []
|
|
316
|
+
for entry in sorted(entries):
|
|
317
|
+
full = os.path.join(path, entry)
|
|
318
|
+
suffix = "/" if os.path.isdir(full) else ""
|
|
319
|
+
lines.append(entry + suffix)
|
|
320
|
+
return "\n".join(lines) if lines else "(empty)"
|
|
321
|
+
except Exception as e:
|
|
322
|
+
return f"Error listing directory: {e}"
|
|
323
|
+
|
|
324
|
+
elif function_name == "fs_search":
|
|
325
|
+
base = data.get("path", "")
|
|
326
|
+
pattern = data.get("pattern", "*")
|
|
327
|
+
query = data.get("query")
|
|
328
|
+
if not _check_allowed_path(base, allowed_paths):
|
|
329
|
+
return f"Error: path '{base}' is not within allowed_paths"
|
|
330
|
+
try:
|
|
331
|
+
matched = []
|
|
332
|
+
for root, dirs, files in os.walk(base):
|
|
333
|
+
# Prune hidden dirs
|
|
334
|
+
dirs[:] = [d for d in dirs if not d.startswith(".")]
|
|
335
|
+
for fname in files:
|
|
336
|
+
if fnmatch.fnmatch(fname, pattern):
|
|
337
|
+
fpath = os.path.join(root, fname)
|
|
338
|
+
if query:
|
|
339
|
+
try:
|
|
340
|
+
with open(fpath, "r", encoding="utf-8", errors="replace") as f:
|
|
341
|
+
if query.lower() in f.read().lower():
|
|
342
|
+
matched.append(fpath)
|
|
343
|
+
except Exception:
|
|
344
|
+
pass
|
|
345
|
+
else:
|
|
346
|
+
matched.append(fpath)
|
|
347
|
+
return "\n".join(matched[:500]) if matched else "(no matches)"
|
|
348
|
+
except Exception as e:
|
|
349
|
+
return f"Error searching: {e}"
|
|
350
|
+
|
|
351
|
+
return f"Error: unknown fs function '{function_name}'"
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
def _execute_shell_delegation(data, allowed_paths, autopilot_level, console, interactive, require_confirm_fn=None):
|
|
355
|
+
"""
|
|
356
|
+
Execute a shell command delegation.
|
|
357
|
+
Returns (output_str, success_bool).
|
|
358
|
+
require_confirm_fn: callable(cmd) -> bool, called when autopilot_level < 2.
|
|
359
|
+
If None, always executes (daemon mode trusts autopilot_level).
|
|
360
|
+
"""
|
|
361
|
+
cmd = data.get("command", "")
|
|
362
|
+
working_dir = data.get("working_directory")
|
|
363
|
+
timeout = data.get("timeout_seconds", 30)
|
|
364
|
+
|
|
365
|
+
if not cmd:
|
|
366
|
+
return "Error: empty command", False
|
|
367
|
+
|
|
368
|
+
# Autopilot gate
|
|
369
|
+
if require_confirm_fn is not None and autopilot_level < 2:
|
|
370
|
+
if not require_confirm_fn(cmd):
|
|
371
|
+
return "Command skipped by user.", False
|
|
372
|
+
|
|
373
|
+
try:
|
|
374
|
+
result = subprocess.run(
|
|
375
|
+
cmd,
|
|
376
|
+
shell=True,
|
|
377
|
+
capture_output=True,
|
|
378
|
+
text=True,
|
|
379
|
+
timeout=timeout,
|
|
380
|
+
cwd=working_dir,
|
|
381
|
+
encoding="utf-8",
|
|
382
|
+
errors="replace",
|
|
383
|
+
)
|
|
384
|
+
parts = []
|
|
385
|
+
if result.stdout:
|
|
386
|
+
parts.append(result.stdout.strip())
|
|
387
|
+
if result.stderr:
|
|
388
|
+
parts.append(f"stderr: {result.stderr.strip()}")
|
|
389
|
+
parts.append(f"exit_code: {result.returncode}")
|
|
390
|
+
return "\n".join(parts), result.returncode == 0
|
|
391
|
+
except subprocess.TimeoutExpired:
|
|
392
|
+
return f"Error: command timed out after {timeout}s", False
|
|
393
|
+
except Exception as e:
|
|
394
|
+
return f"Error: {e}", False
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
def _post_function_result_msg(base_url, token, verify_https, project, parent_id, function_name, result_content, tool_id):
|
|
398
|
+
"""Post a #function_result message as a child of parent_id (the delegation message)."""
|
|
399
|
+
fr = {"role": "function", "name": function_name, "content": result_content, "id": tool_id or ""}
|
|
400
|
+
body = f"@{project}\n#function_result\n{json.dumps(fr)}"
|
|
401
|
+
url = f"{base_url}app/AddChatMessage"
|
|
402
|
+
payload = {"content": body, "type": "ai-chat-answer2", "parentId": str(parent_id)}
|
|
403
|
+
try:
|
|
404
|
+
resp = requests.post(
|
|
405
|
+
url,
|
|
406
|
+
json=payload,
|
|
407
|
+
headers={"Authorization": f"Bearer {token}"},
|
|
408
|
+
verify=verify_https,
|
|
409
|
+
timeout=30,
|
|
410
|
+
)
|
|
411
|
+
resp.raise_for_status()
|
|
412
|
+
return resp.json()
|
|
413
|
+
except Exception as e:
|
|
414
|
+
logger.error(f"Failed to post function result: {e}")
|
|
415
|
+
return None
|
|
416
|
+
|
|
417
|
+
|
|
418
|
+
def _start_chat_async(base_url, token, verify_https, project, message, conversation_id=None):
|
|
419
|
+
"""Post chat message via StartAi2ChatAsync and return the promptMessageId."""
|
|
420
|
+
payload = {"message": message, "project": project}
|
|
421
|
+
if conversation_id:
|
|
422
|
+
payload["messageId"] = str(conversation_id)
|
|
423
|
+
url = f"{base_url}app/StartAi2ChatAsync"
|
|
424
|
+
resp = requests.post(
|
|
425
|
+
url,
|
|
426
|
+
json=payload,
|
|
427
|
+
headers={"Authorization": f"Bearer {token}"},
|
|
428
|
+
verify=verify_https,
|
|
429
|
+
timeout=30,
|
|
430
|
+
)
|
|
431
|
+
resp.raise_for_status()
|
|
432
|
+
return resp.json().get("promptMessageId")
|
|
433
|
+
|
|
434
|
+
|
|
435
|
+
def _fetch_history(base_url, token, verify_https, message_id):
|
|
436
|
+
"""Fetch conversation history chain for the given message_id."""
|
|
437
|
+
url = f"{base_url}app/GetMessageHistory"
|
|
438
|
+
resp = requests.get(
|
|
439
|
+
url,
|
|
440
|
+
params={"messageId": str(message_id)},
|
|
441
|
+
headers={"Authorization": f"Bearer {token}"},
|
|
442
|
+
verify=verify_https,
|
|
443
|
+
timeout=15,
|
|
444
|
+
)
|
|
445
|
+
resp.raise_for_status()
|
|
446
|
+
return resp.json() if resp.text else []
|
|
447
|
+
|
|
448
|
+
|
|
449
|
+
def _wait_for_ai_reply_with_delegation(
|
|
450
|
+
base_url, token, verify_https, project, prompt_message_id,
|
|
451
|
+
on_delegation, poll_interval=1.5, timeout=360
|
|
452
|
+
):
|
|
453
|
+
"""
|
|
454
|
+
Poll GetMessageHistory until a final text reply is found.
|
|
455
|
+
Calls on_delegation(msg_content, msg_id) when a delegation is detected.
|
|
456
|
+
Returns {id, content} of the final text message, or None on timeout.
|
|
457
|
+
"""
|
|
458
|
+
import time
|
|
459
|
+
deadline = time.time() + timeout
|
|
460
|
+
last_seen_id = None
|
|
461
|
+
|
|
462
|
+
while time.time() < deadline:
|
|
463
|
+
try:
|
|
464
|
+
history = _fetch_history(base_url, token, verify_https, prompt_message_id)
|
|
465
|
+
except Exception as e:
|
|
466
|
+
logger.warning(f"History fetch error: {e}")
|
|
467
|
+
time.sleep(poll_interval)
|
|
468
|
+
continue
|
|
469
|
+
|
|
470
|
+
if not history or len(history) < 2:
|
|
471
|
+
time.sleep(poll_interval)
|
|
472
|
+
continue
|
|
473
|
+
|
|
474
|
+
# Latest message in the chain (leaf)
|
|
475
|
+
last = history[-1]
|
|
476
|
+
last_id = last.get("id") or last.get("Id")
|
|
477
|
+
content = last.get("content") or last.get("Content", "")
|
|
478
|
+
|
|
479
|
+
if last_id == last_seen_id:
|
|
480
|
+
time.sleep(poll_interval)
|
|
481
|
+
continue
|
|
482
|
+
last_seen_id = last_id
|
|
483
|
+
|
|
484
|
+
# Check for delegation in function_result messages
|
|
485
|
+
if "#function_result" in content:
|
|
486
|
+
delegation = _parse_delegation(content)
|
|
487
|
+
if delegation:
|
|
488
|
+
on_delegation(content, last_id)
|
|
489
|
+
time.sleep(poll_interval)
|
|
490
|
+
continue
|
|
491
|
+
|
|
492
|
+
# Check if this is an intermediate (non-finished) engine state
|
|
493
|
+
intermediate_markers = [
|
|
494
|
+
"#chat2", "#finish_reason_function_call", "#finish_reason_tool_calls_batch",
|
|
495
|
+
"#function_result", "#function_results_batch",
|
|
496
|
+
]
|
|
497
|
+
if any(m in content for m in intermediate_markers):
|
|
498
|
+
time.sleep(poll_interval)
|
|
499
|
+
continue
|
|
500
|
+
|
|
501
|
+
# Finished: strip @mention and leading tags for display
|
|
502
|
+
clean = re.sub(r"^@\S+\s*", "", content).strip()
|
|
503
|
+
clean = re.sub(r"^(#\S+\s*)+", "", clean).strip()
|
|
504
|
+
return {"id": last_id, "content": clean}
|
|
505
|
+
|
|
506
|
+
return None # timeout
|
|
507
|
+
|
|
166
508
|
def main():
|
|
167
509
|
console = Console()
|
|
168
510
|
|
|
@@ -542,6 +884,124 @@ def main():
|
|
|
542
884
|
return 0 if failed == 0 else 1
|
|
543
885
|
|
|
544
886
|
# Handle wrap command
|
|
887
|
+
# -----------------------------------------------------------------------
|
|
888
|
+
# daemon subcommand — persistent remote AI outpost
|
|
889
|
+
# -----------------------------------------------------------------------
|
|
890
|
+
if len(sys.argv) > 1 and sys.argv[1] == "daemon":
|
|
891
|
+
import time
|
|
892
|
+
import uuid
|
|
893
|
+
|
|
894
|
+
daemon_token = auth.get_cached_token()
|
|
895
|
+
if not daemon_token:
|
|
896
|
+
daemon_token = os.getenv("CODICENT_TOKEN")
|
|
897
|
+
if not daemon_token:
|
|
898
|
+
console.print("[red]❌ No authentication found.[/red]")
|
|
899
|
+
console.print("[dim]Run 'codicent auth' to authenticate, or set CODICENT_TOKEN[/dim]")
|
|
900
|
+
return 1
|
|
901
|
+
|
|
902
|
+
try:
|
|
903
|
+
dp = daemon_token.split(".")[1]
|
|
904
|
+
dp += '=' * (-len(dp) % 4)
|
|
905
|
+
daemon_jwt = json.loads(base64.urlsafe_b64decode(dp).decode('utf-8'))
|
|
906
|
+
daemon_project = daemon_jwt["project"]
|
|
907
|
+
daemon_base_url = daemon_jwt.get("baseUrl", "https://codicent.com/")
|
|
908
|
+
daemon_verify = True
|
|
909
|
+
except Exception as e:
|
|
910
|
+
console.print(f"[red]❌ Failed to extract project from token: {e}[/red]")
|
|
911
|
+
return 1
|
|
912
|
+
|
|
913
|
+
daemon_config = load_cli_config()
|
|
914
|
+
daemon_autopilot = daemon_config.get("autopilot_level", 0)
|
|
915
|
+
daemon_allowed = daemon_config.get("allowed_paths", [])
|
|
916
|
+
daemon_poll_interval = daemon_config.get("daemon", {}).get("poll_interval", 5)
|
|
917
|
+
|
|
918
|
+
# Stable session identity — persistent across restarts when using the same token
|
|
919
|
+
session_id = daemon_jwt.get("sub", "")[:16] or str(uuid.uuid4())[:8]
|
|
920
|
+
console.print(f"[bold green]🤖 Codicent Daemon started[/bold green] (session={session_id})")
|
|
921
|
+
console.print(f"[dim]Project: {daemon_project} | Autopilot level: {daemon_autopilot} | Poll: {daemon_poll_interval}s[/dim]")
|
|
922
|
+
if daemon_allowed:
|
|
923
|
+
console.print(f"[dim]Allowed paths: {daemon_allowed}[/dim]")
|
|
924
|
+
|
|
925
|
+
# Track delegation messages we have posted results for (avoid double-execution)
|
|
926
|
+
executed_ids = set()
|
|
927
|
+
|
|
928
|
+
# We watch for delegation markers in "pending" messages addressed via session_id
|
|
929
|
+
# The engine embeds session_id in every delegation JSON if provided in the tool call.
|
|
930
|
+
# Format: @project #function_result {"role":"function","name":"...","content":"#execute_command\n{...session_id:...}","id":"..."}
|
|
931
|
+
|
|
932
|
+
def _fetch_pending_delegations():
|
|
933
|
+
"""Fetch unhandled (leaf) function_result messages for this project."""
|
|
934
|
+
url = f"{daemon_base_url}app/GetPendingDelegations"
|
|
935
|
+
try:
|
|
936
|
+
resp = requests.get(
|
|
937
|
+
url,
|
|
938
|
+
params={"project": daemon_project, "maxMessages": 50},
|
|
939
|
+
headers={"Authorization": f"Bearer {daemon_token}"},
|
|
940
|
+
verify=daemon_verify,
|
|
941
|
+
timeout=15,
|
|
942
|
+
)
|
|
943
|
+
resp.raise_for_status()
|
|
944
|
+
return resp.json() if resp.text else []
|
|
945
|
+
except Exception as e:
|
|
946
|
+
logger.debug(f"Daemon poll error: {e}")
|
|
947
|
+
return []
|
|
948
|
+
|
|
949
|
+
try:
|
|
950
|
+
while True:
|
|
951
|
+
msgs = _fetch_pending_delegations()
|
|
952
|
+
for msg in msgs:
|
|
953
|
+
msg_id = msg.get("id") or msg.get("Id")
|
|
954
|
+
if not msg_id or msg_id in executed_ids:
|
|
955
|
+
continue
|
|
956
|
+
content = msg.get("content") or msg.get("Content", "")
|
|
957
|
+
if "#function_result" not in content:
|
|
958
|
+
continue
|
|
959
|
+
|
|
960
|
+
delegation = _parse_delegation(content)
|
|
961
|
+
if not delegation:
|
|
962
|
+
continue
|
|
963
|
+
|
|
964
|
+
func_name, op, data, tool_id = delegation
|
|
965
|
+
|
|
966
|
+
# Filter by session_id if present in the delegation data
|
|
967
|
+
msg_session = data.get("session_id", "")
|
|
968
|
+
if msg_session and msg_session != session_id:
|
|
969
|
+
continue
|
|
970
|
+
|
|
971
|
+
# Mark as handled immediately to avoid duplicate execution
|
|
972
|
+
executed_ids.add(msg_id)
|
|
973
|
+
|
|
974
|
+
console.print(f"\n[bold cyan]→ Delegation:[/bold cyan] {op} | msg_id={msg_id}")
|
|
975
|
+
|
|
976
|
+
if op == "execute_command":
|
|
977
|
+
cmd = data.get("command", "")
|
|
978
|
+
console.print(f" cmd: {cmd}")
|
|
979
|
+
if daemon_autopilot < 1:
|
|
980
|
+
# Non-interactive: always require autopilot_level >= 1 for daemon
|
|
981
|
+
result_str = "Daemon requires autopilot_level >= 1 for execute_command. Set in ~/.codicent/wrap-config.yaml."
|
|
982
|
+
else:
|
|
983
|
+
result_str, _ = _execute_shell_delegation(
|
|
984
|
+
data, daemon_allowed, daemon_autopilot, console, False, None
|
|
985
|
+
)
|
|
986
|
+
else:
|
|
987
|
+
result_str = _execute_fs_delegation(op, data, daemon_allowed)
|
|
988
|
+
|
|
989
|
+
console.print(f" result: {result_str[:160]}{'…' if len(result_str) > 160 else ''}")
|
|
990
|
+
_post_function_result_msg(
|
|
991
|
+
daemon_base_url, daemon_token, daemon_verify,
|
|
992
|
+
daemon_project, msg_id, func_name, result_str, tool_id
|
|
993
|
+
)
|
|
994
|
+
|
|
995
|
+
# Trim executed_ids if it gets large
|
|
996
|
+
if len(executed_ids) > 2000:
|
|
997
|
+
executed_ids = set(list(executed_ids)[-1000:])
|
|
998
|
+
|
|
999
|
+
time.sleep(daemon_poll_interval)
|
|
1000
|
+
|
|
1001
|
+
except KeyboardInterrupt:
|
|
1002
|
+
console.print("\n[yellow]Daemon stopped.[/yellow]")
|
|
1003
|
+
return 0
|
|
1004
|
+
|
|
545
1005
|
if len(sys.argv) > 1 and sys.argv[1] == "wrap":
|
|
546
1006
|
from wrap import run_wrap_command
|
|
547
1007
|
|
|
@@ -573,6 +1033,31 @@ def main():
|
|
|
573
1033
|
project=wrap_project,
|
|
574
1034
|
)
|
|
575
1035
|
|
|
1036
|
+
# Handle tui command
|
|
1037
|
+
if len(sys.argv) > 1 and sys.argv[1] == "tui":
|
|
1038
|
+
from tui import run_tui
|
|
1039
|
+
|
|
1040
|
+
tui_token = auth.get_cached_token()
|
|
1041
|
+
if not tui_token:
|
|
1042
|
+
tui_token = os.getenv("CODICENT_TOKEN")
|
|
1043
|
+
if not tui_token:
|
|
1044
|
+
console.print("[red]❌ No authentication found.[/red]")
|
|
1045
|
+
console.print("[dim]Run 'codicent auth' to authenticate, or set CODICENT_TOKEN[/dim]")
|
|
1046
|
+
return 1
|
|
1047
|
+
|
|
1048
|
+
try:
|
|
1049
|
+
tui_payload = tui_token.split(".")[1]
|
|
1050
|
+
tui_payload += '=' * (-len(tui_payload) % 4)
|
|
1051
|
+
tui_jwt = json.loads(base64.urlsafe_b64decode(tui_payload).decode('utf-8'))
|
|
1052
|
+
tui_project = tui_jwt["project"]
|
|
1053
|
+
except Exception as e:
|
|
1054
|
+
console.print(f"[red]❌ Failed to extract project from token: {e}[/red]")
|
|
1055
|
+
return 1
|
|
1056
|
+
|
|
1057
|
+
tui_codicent = Codicent(tui_token)
|
|
1058
|
+
run_tui(tui_codicent, tui_project)
|
|
1059
|
+
return 0
|
|
1060
|
+
|
|
576
1061
|
# Handle terminal command
|
|
577
1062
|
if len(sys.argv) > 1 and sys.argv[1] == "terminal":
|
|
578
1063
|
from terminal import run_terminal_host
|
|
@@ -627,6 +1112,9 @@ def main():
|
|
|
627
1112
|
return 1
|
|
628
1113
|
|
|
629
1114
|
conversationId = None
|
|
1115
|
+
cli_config = load_cli_config()
|
|
1116
|
+
autopilot_level = cli_config.get("autopilot_level", 0)
|
|
1117
|
+
allowed_paths = cli_config.get("allowed_paths", [])
|
|
630
1118
|
|
|
631
1119
|
# Parse interactive mode flags
|
|
632
1120
|
interactive = False
|
|
@@ -682,30 +1170,92 @@ def main():
|
|
|
682
1170
|
console.print("[green]✅ Message posted successfully.[/green]")
|
|
683
1171
|
else:
|
|
684
1172
|
logger.info("Sending chat reply to Codicent API")
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
1173
|
+
|
|
1174
|
+
# --- Async delegation loop ---
|
|
1175
|
+
def handle_delegation(msg_content, msg_id):
|
|
1176
|
+
"""Called when a delegation message is found during inline wait."""
|
|
1177
|
+
delegation = _parse_delegation(msg_content)
|
|
1178
|
+
if not delegation:
|
|
1179
|
+
return
|
|
1180
|
+
func_name, op, data, tool_id = delegation
|
|
1181
|
+
|
|
1182
|
+
if op == "execute_command":
|
|
1183
|
+
cmd = data.get("command", "")
|
|
1184
|
+
console.print(f"\n[bold yellow]⚡ AI wants to run:[/bold yellow] {cmd}")
|
|
1185
|
+
|
|
1186
|
+
def confirm_fn(c):
|
|
1187
|
+
if autopilot_level >= 2:
|
|
1188
|
+
return True
|
|
1189
|
+
if not interactive:
|
|
1190
|
+
return autopilot_level >= 1
|
|
1191
|
+
try:
|
|
1192
|
+
ans = prompt("Allow? (y/N) ")
|
|
1193
|
+
return ans.strip().lower() == "y"
|
|
1194
|
+
except (KeyboardInterrupt, EOFError):
|
|
1195
|
+
return False
|
|
1196
|
+
|
|
1197
|
+
with console.status("[dim]Running command...[/dim]", spinner="dots"):
|
|
1198
|
+
result_str, _ = _execute_shell_delegation(
|
|
1199
|
+
data, allowed_paths, autopilot_level, console, interactive, confirm_fn
|
|
1200
|
+
)
|
|
1201
|
+
console.print(f"[dim]Result: {result_str[:200]}{'…' if len(result_str) > 200 else ''}[/dim]")
|
|
1202
|
+
_post_function_result_msg(
|
|
1203
|
+
codicent.base_url, token, codicent.verify_https,
|
|
1204
|
+
project, msg_id, func_name, result_str, tool_id
|
|
1205
|
+
)
|
|
1206
|
+
|
|
1207
|
+
elif op in ("fs_read", "fs_write", "fs_list", "fs_search"):
|
|
1208
|
+
path = data.get("path", "")
|
|
1209
|
+
console.print(f"\n[bold yellow]📂 AI filesystem op:[/bold yellow] {op} {path}")
|
|
1210
|
+
if not _check_allowed_path(path, allowed_paths):
|
|
1211
|
+
result_str = f"Error: path '{path}' is not within allowed_paths"
|
|
1212
|
+
else:
|
|
1213
|
+
with console.status("[dim]Executing...[/dim]", spinner="dots"):
|
|
1214
|
+
result_str = _execute_fs_delegation(op, data, allowed_paths)
|
|
1215
|
+
console.print(f"[dim]{result_str[:200]}{'…' if len(result_str) > 200 else ''}[/dim]")
|
|
1216
|
+
_post_function_result_msg(
|
|
1217
|
+
codicent.base_url, token, codicent.verify_https,
|
|
1218
|
+
project, msg_id, func_name, result_str, tool_id
|
|
1219
|
+
)
|
|
1220
|
+
|
|
1221
|
+
try:
|
|
1222
|
+
prompt_id = _start_chat_async(
|
|
1223
|
+
codicent.base_url, token, codicent.verify_https,
|
|
1224
|
+
project, question, conversationId
|
|
1225
|
+
)
|
|
1226
|
+
except Exception:
|
|
1227
|
+
# Fallback to synchronous post_chat_reply (no delegation support)
|
|
1228
|
+
with console.status("[dim]🤔 Thinking...[/dim]", spinner="dots"):
|
|
1229
|
+
response = codicent.post_chat_reply(question, conversationId)
|
|
1230
|
+
conversationId = response["id"]
|
|
1231
|
+
content = response["content"]
|
|
1232
|
+
else:
|
|
1233
|
+
with console.status("[dim]🤔 Thinking...[/dim]", spinner="dots"):
|
|
1234
|
+
response = _wait_for_ai_reply_with_delegation(
|
|
1235
|
+
codicent.base_url, token, codicent.verify_https,
|
|
1236
|
+
project, prompt_id, handle_delegation
|
|
1237
|
+
)
|
|
1238
|
+
if response is None:
|
|
1239
|
+
console.print("[red]⏱ Request timed out or no reply received.[/red]")
|
|
1240
|
+
return False
|
|
1241
|
+
conversationId = response["id"]
|
|
1242
|
+
content = response["content"]
|
|
1243
|
+
|
|
688
1244
|
logger.info(f"Updated conversation ID: {conversationId}")
|
|
689
|
-
|
|
1245
|
+
|
|
690
1246
|
# Show bot response with markdown formatting in green
|
|
691
1247
|
if interactive:
|
|
692
1248
|
console.print()
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
# Detect shell: markers in AI response
|
|
1249
|
+
|
|
1250
|
+
# Backward-compat: detect legacy shell: markers in AI response
|
|
697
1251
|
shell_commands = re.findall(r'^shell:(.+)$', content, re.MULTILINE)
|
|
698
|
-
|
|
699
|
-
# Also detect ```shell code blocks
|
|
700
1252
|
shell_blocks = re.findall(r'```shell\n(.*?)```', content, re.DOTALL)
|
|
701
|
-
|
|
702
|
-
# Create markdown with green styling
|
|
1253
|
+
|
|
703
1254
|
from rich.text import Text
|
|
704
1255
|
markdown_content = Markdown(content)
|
|
705
1256
|
console.print(markdown_content, style="green")
|
|
706
1257
|
console.print()
|
|
707
|
-
|
|
708
|
-
# Offer to run detected shell commands
|
|
1258
|
+
|
|
709
1259
|
all_commands = shell_commands + [block.strip() for block in shell_blocks]
|
|
710
1260
|
for cmd in all_commands:
|
|
711
1261
|
cmd = cmd.strip()
|
|
@@ -720,7 +1270,6 @@ def main():
|
|
|
720
1270
|
if confirm.strip().lower() == 'y':
|
|
721
1271
|
console.print(f"[dim]Running: {cmd}[/dim]")
|
|
722
1272
|
try:
|
|
723
|
-
# Run command with proper encoding handling for cross-platform compatibility
|
|
724
1273
|
result = subprocess.run(
|
|
725
1274
|
cmd,
|
|
726
1275
|
shell=True,
|
|
@@ -728,10 +1277,9 @@ def main():
|
|
|
728
1277
|
text=True,
|
|
729
1278
|
timeout=60,
|
|
730
1279
|
encoding='utf-8',
|
|
731
|
-
errors='replace'
|
|
1280
|
+
errors='replace'
|
|
732
1281
|
)
|
|
733
1282
|
|
|
734
|
-
# Display output to user
|
|
735
1283
|
if result.stdout:
|
|
736
1284
|
console.print(result.stdout)
|
|
737
1285
|
if result.stderr:
|
|
@@ -741,57 +1289,37 @@ def main():
|
|
|
741
1289
|
else:
|
|
742
1290
|
console.print(f"[red]Exit code: {result.returncode}[/red]")
|
|
743
1291
|
|
|
744
|
-
|
|
745
|
-
output_parts = []
|
|
746
|
-
output_parts.append(f"Command executed: `{cmd}`")
|
|
747
|
-
output_parts.append("")
|
|
1292
|
+
output_parts = [f"Command executed: `{cmd}`", ""]
|
|
748
1293
|
if result.stdout:
|
|
749
|
-
output_parts
|
|
750
|
-
output_parts.append("```")
|
|
751
|
-
output_parts.append(result.stdout.strip())
|
|
752
|
-
output_parts.append("```")
|
|
1294
|
+
output_parts += ["Output:", "```", result.stdout.strip(), "```"]
|
|
753
1295
|
if result.stderr:
|
|
754
|
-
output_parts
|
|
755
|
-
output_parts.append("```")
|
|
756
|
-
output_parts.append(result.stderr.strip())
|
|
757
|
-
output_parts.append("```")
|
|
1296
|
+
output_parts += ["Errors:", "```", result.stderr.strip(), "```"]
|
|
758
1297
|
output_parts.append(f"Exit code: {result.returncode}")
|
|
759
|
-
|
|
760
1298
|
command_output = "\n".join(output_parts)
|
|
761
1299
|
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
1300
|
+
try:
|
|
1301
|
+
prompt_id2 = _start_chat_async(
|
|
1302
|
+
codicent.base_url, token, codicent.verify_https,
|
|
1303
|
+
project, command_output, conversationId
|
|
1304
|
+
)
|
|
1305
|
+
with console.status("[dim]Sending output to AI...[/dim]", spinner="dots"):
|
|
1306
|
+
resp2 = _wait_for_ai_reply_with_delegation(
|
|
1307
|
+
codicent.base_url, token, codicent.verify_https,
|
|
1308
|
+
project, prompt_id2, handle_delegation
|
|
1309
|
+
)
|
|
1310
|
+
if resp2:
|
|
1311
|
+
conversationId = resp2["id"]
|
|
1312
|
+
console.print()
|
|
1313
|
+
console.print("[dim]AI response:[/dim]")
|
|
1314
|
+
console.print(Markdown(resp2["content"]), style="green")
|
|
1315
|
+
console.print()
|
|
1316
|
+
except Exception as e:
|
|
1317
|
+
logger.warning(f"Could not send shell output back to AI: {e}")
|
|
774
1318
|
|
|
775
1319
|
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
|
|
1320
|
+
console.print("[red]Command timed out after 60 seconds[/red]")
|
|
785
1321
|
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
|
|
1322
|
+
console.print(f"[red]Error: {e}[/red]")
|
|
795
1323
|
else:
|
|
796
1324
|
console.print("[dim]Skipped.[/dim]")
|
|
797
1325
|
else:
|
|
@@ -102,7 +102,7 @@ def run_terminal_host(token: str, base_url: str, project: str, shell: str = None
|
|
|
102
102
|
_set_pty_size(cols, rows)
|
|
103
103
|
|
|
104
104
|
def on_open(ws):
|
|
105
|
-
browser_url = f"{
|
|
105
|
+
browser_url = f"{base_url.rstrip('/')}/terminal/ws?sessionId={session_id}"
|
|
106
106
|
print(f"[codicent terminal] Connected. Shell is ready in the browser.")
|
|
107
107
|
print(f"[codicent terminal] Browser connect: {browser_url}")
|
|
108
108
|
# Send initial resize
|
|
@@ -202,7 +202,7 @@ def run_terminal_host(token: str, base_url: str, project: str, shell: str = None
|
|
|
202
202
|
threading.Thread(target=_stdout_reader, daemon=True).start()
|
|
203
203
|
|
|
204
204
|
def on_open(ws):
|
|
205
|
-
browser_url = f"{
|
|
205
|
+
browser_url = f"{base_url.rstrip('/')}/terminal/ws?sessionId={session_id}"
|
|
206
206
|
print(f"[codicent terminal] Connected.")
|
|
207
207
|
print(f"[codicent terminal] Browser connect: {browser_url}")
|
|
208
208
|
|
|
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
|